mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Support InvokeFunctionTool for declarative workflows (#4014)
* Initial Implementation of InvokeFunctionTool * Added unit test for InvokeFunctionTool executor. * Implemented unit and integration tests for InvokeFunctionTool. * Add sample for InvokeFunctionTool in declarative workflows. * Remove unused sample and updated comments. * Updating to official OM release with InvokeFunctionTool * Fix formatting issues. * Updated PowerFx version * Update test fixture * Cleanup - Removed unused method in InvokeFunctionToolExecutor * Update test based on PR feedback. * Update based on PR comments
This commit is contained in:
committed by
GitHub
Unverified
parent
7cee839982
commit
988ef6a50e
+312
@@ -0,0 +1,312 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
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;
|
||||
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.Agents.ObjectModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
|
||||
|
||||
/// <summary>
|
||||
/// Executor for the <see cref="InvokeFunctionTool"/> action.
|
||||
/// This executor yields to the caller for function execution and resumes when results are provided.
|
||||
/// </summary>
|
||||
internal sealed class InvokeFunctionToolExecutor(
|
||||
InvokeFunctionTool model,
|
||||
ResponseAgentProvider agentProvider,
|
||||
WorkflowFormulaState state) :
|
||||
DeclarativeActionExecutor<InvokeFunctionTool>(model, state)
|
||||
{
|
||||
/// <summary>
|
||||
/// Step identifiers for the function tool invocation workflow.
|
||||
/// </summary>
|
||||
public static class Steps
|
||||
{
|
||||
/// <summary>
|
||||
/// Step for waiting for external input (function result).
|
||||
/// </summary>
|
||||
public static string ExternalInput(string id) => $"{id}_{nameof(ExternalInput)}";
|
||||
|
||||
/// <summary>
|
||||
/// Step for resuming after receiving function result.
|
||||
/// </summary>
|
||||
public static string Resume(string id) => $"{id}_{nameof(Resume)}";
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override bool EmitResultEvent => false;
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override bool IsDiscreteAction => false;
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string functionName = this.GetFunctionName();
|
||||
bool requireApproval = this.GetRequireApproval();
|
||||
Dictionary<string, object?>? arguments = this.GetArguments();
|
||||
|
||||
// Create the function call content to send to the caller
|
||||
FunctionCallContent functionCall = new(
|
||||
callId: this.Id,
|
||||
name: functionName,
|
||||
arguments: arguments);
|
||||
|
||||
// Build the response with the function call request
|
||||
ChatMessage requestMessage = new(ChatRole.Tool, [functionCall]);
|
||||
|
||||
// If approval is required, add user input request content
|
||||
if (requireApproval)
|
||||
{
|
||||
requestMessage.Contents.Add(new FunctionApprovalRequestContent(this.Id, functionCall));
|
||||
}
|
||||
|
||||
AgentResponse agentResponse = new([requestMessage]);
|
||||
|
||||
// Yield to the caller - workflow halts here until external input is received
|
||||
ExternalInputRequest inputRequest = new(agentResponse);
|
||||
await context.SendMessageAsync(inputRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures the function result and stores in output variables.
|
||||
/// </summary>
|
||||
/// <param name="context">The workflow context.</param>
|
||||
/// <param name="response">The external input response containing the function result.</param>
|
||||
/// <param name="cancellationToken">A cancellation token.</param>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
public async ValueTask CaptureResponseAsync(
|
||||
IWorkflowContext context,
|
||||
ExternalInputResponse response,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
bool autoSend = this.GetAutoSendValue();
|
||||
string? conversationId = this.GetConversationId();
|
||||
|
||||
// Extract function results from the response
|
||||
IEnumerable<FunctionResultContent> functionResults = response.Messages
|
||||
.SelectMany(m => m.Contents)
|
||||
.OfType<FunctionResultContent>();
|
||||
|
||||
FunctionResultContent? matchingResult = functionResults
|
||||
.FirstOrDefault(r => r.CallId == this.Id);
|
||||
|
||||
if (matchingResult is not null)
|
||||
{
|
||||
// Store the result in output variable
|
||||
await this.AssignResultAsync(context, matchingResult).ConfigureAwait(false);
|
||||
|
||||
// Auto-send the result if configured
|
||||
if (autoSend)
|
||||
{
|
||||
AgentResponse resultResponse = new([new ChatMessage(ChatRole.Tool, [matchingResult])]);
|
||||
await context.AddEventAsync(new AgentResponseEvent(this.Id, resultResponse), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Store messages if output path is configured
|
||||
if (this.Model.Output?.Messages is not null)
|
||||
{
|
||||
await this.AssignAsync(this.Model.Output.Messages?.Path, response.Messages.ToFormula(), context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Add messages to conversation if conversationId is provided
|
||||
// Note: We transform messages containing FunctionResultContent or FunctionCallContent
|
||||
// to assistant text messages because workflow-generated CallIds don't correspond to
|
||||
// actual AI-generated tool calls and would be rejected by the API.
|
||||
if (conversationId is not null)
|
||||
{
|
||||
foreach (ChatMessage message in TransformConversationMessages(response.Messages))
|
||||
{
|
||||
await agentProvider.CreateMessageAsync(conversationId, message, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Completes the action after processing the function result.
|
||||
await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transforms messages containing function-related content to assistant text messages.
|
||||
/// Messages with FunctionResultContent are converted to assistant messages with the result as text.
|
||||
/// Messages with only FunctionCallContent are excluded as they have no informational value.
|
||||
/// </summary>
|
||||
private static IEnumerable<ChatMessage> TransformConversationMessages(IEnumerable<ChatMessage> messages)
|
||||
{
|
||||
foreach (ChatMessage message in messages)
|
||||
{
|
||||
// Check if message contains function content
|
||||
bool hasFunctionResult = message.Contents.OfType<FunctionResultContent>().Any();
|
||||
bool hasFunctionCall = message.Contents.OfType<FunctionCallContent>().Any();
|
||||
|
||||
if (hasFunctionResult)
|
||||
{
|
||||
// Convert function results to assistant text message
|
||||
List<AIContent> updatedContents = [];
|
||||
foreach (AIContent content in message.Contents)
|
||||
{
|
||||
if (content is FunctionResultContent functionResult)
|
||||
{
|
||||
string? resultText = functionResult.Result?.ToString();
|
||||
if (!string.IsNullOrEmpty(resultText))
|
||||
{
|
||||
updatedContents.Add(new TextContent($"[Function {functionResult.CallId} result]: {resultText}"));
|
||||
}
|
||||
}
|
||||
else if (content is not FunctionCallContent)
|
||||
{
|
||||
// Keep non-function content as-is
|
||||
updatedContents.Add(content);
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedContents.Count > 0)
|
||||
{
|
||||
yield return new ChatMessage(ChatRole.Assistant, updatedContents);
|
||||
}
|
||||
}
|
||||
else if (!hasFunctionCall)
|
||||
{
|
||||
// Pass through messages without function content
|
||||
yield return message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask AssignResultAsync(IWorkflowContext context, FunctionResultContent result)
|
||||
{
|
||||
if (this.Model.Output?.Result is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
object? resultValue = result.Result;
|
||||
|
||||
// Attempt to parse as JSON if it's a string
|
||||
if (resultValue is string jsonString)
|
||||
{
|
||||
try
|
||||
{
|
||||
using JsonDocument jsonDocument = JsonDocument.Parse(jsonString);
|
||||
// Handle different JSON value kinds
|
||||
object? parsedValue = jsonDocument.RootElement.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object => jsonDocument.ParseRecord(VariableType.RecordType),
|
||||
JsonValueKind.Array => jsonDocument.ParseList(CreateListTypeFromJson(jsonDocument.RootElement)),
|
||||
JsonValueKind.String => jsonDocument.RootElement.GetString(),
|
||||
JsonValueKind.Number => jsonDocument.RootElement.TryGetInt64(out long l) ? l : jsonDocument.RootElement.GetDouble(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null => null,
|
||||
_ => jsonString,
|
||||
};
|
||||
await this.AssignAsync(this.Model.Output.Result?.Path, parsedValue.ToFormula(), context).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not a valid JSON
|
||||
}
|
||||
}
|
||||
|
||||
await this.AssignAsync(this.Model.Output.Result?.Path, resultValue.ToFormula(), context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a VariableType.List with schema inferred from the first object element in the array.
|
||||
/// </summary>
|
||||
private static VariableType CreateListTypeFromJson(JsonElement arrayElement)
|
||||
{
|
||||
// Find the first object element to infer schema
|
||||
foreach (JsonElement element in arrayElement.EnumerateArray())
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
// Build schema from the object's properties
|
||||
List<(string Key, VariableType Type)> fields = [];
|
||||
foreach (JsonProperty property in element.EnumerateObject())
|
||||
{
|
||||
VariableType fieldType = property.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => typeof(string),
|
||||
JsonValueKind.Number => typeof(decimal),
|
||||
JsonValueKind.True or JsonValueKind.False => typeof(bool),
|
||||
JsonValueKind.Object => VariableType.RecordType,
|
||||
JsonValueKind.Array => VariableType.ListType,
|
||||
_ => typeof(string),
|
||||
};
|
||||
fields.Add((property.Name, fieldType));
|
||||
}
|
||||
|
||||
return VariableType.List(fields);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for arrays of primitives or empty arrays
|
||||
return VariableType.ListType;
|
||||
}
|
||||
|
||||
private string GetFunctionName() =>
|
||||
this.Evaluator.GetValue(
|
||||
Throw.IfNull(
|
||||
this.Model.FunctionName,
|
||||
$"{nameof(this.Model)}.{nameof(this.Model.FunctionName)}")).Value;
|
||||
|
||||
private string? GetConversationId()
|
||||
{
|
||||
if (this.Model.ConversationId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string conversationIdValue = this.Evaluator.GetValue(this.Model.ConversationId).Value;
|
||||
return conversationIdValue.Length == 0 ? null : conversationIdValue;
|
||||
}
|
||||
|
||||
private bool GetRequireApproval()
|
||||
{
|
||||
if (this.Model.RequireApproval is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.Evaluator.GetValue(this.Model.RequireApproval).Value;
|
||||
}
|
||||
|
||||
private bool GetAutoSendValue()
|
||||
{
|
||||
if (this.Model.Output?.AutoSend is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.Evaluator.GetValue(this.Model.Output.AutoSend).Value;
|
||||
}
|
||||
|
||||
private Dictionary<string, object?>? GetArguments()
|
||||
{
|
||||
if (this.Model.Arguments is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Dictionary<string, object?> result = [];
|
||||
foreach (KeyValuePair<string, ValueExpression> argument in this.Model.Arguments)
|
||||
{
|
||||
result[argument.Key] = this.Evaluator.GetValue(argument.Value).Value.ToObject();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user