Files
agent-framework/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs
T
Copilot 88ea9d08c7 .NET: Update to OpenAI 2.9.1, Azure.AI.OpenAI 2.9.0-beta.1, Microsoft.Extensions.AI 10.4.0, and Azure.AI.Projects 2.0.0-beta.2 (#4613)
* Initial plan

* Update code for Microsoft.Extensions.AI.Abstractions 10.4.0 breaking changes

- Rename FunctionApprovalRequestContent → ToolApprovalRequestContent
- Rename FunctionApprovalResponseContent → ToolApprovalResponseContent
- Rename UserInputRequestContent → ToolApprovalRequestContent
- Rename UserInputResponseContent → ToolApprovalResponseContent
- Update .FunctionCall property → .ToolCall with FunctionCallContent casts where needed
- Update .Id property → .RequestId on the renamed types
- Rename FunctionApprovalRequestEventGenerator → ToolApprovalRequestEventGenerator
- Rename FunctionApprovalResponseEventGenerator → ToolApprovalResponseEventGenerator

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Update OpenAI 2.9.1, ME.AI 10.4.0, fix breaking API changes

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>

* Fix remaining ME.AI 10.4.0 breaking changes: MCP approval types, .Output→.Outputs

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>

* Use pattern matching with `when` for ToolApprovalRequestContent/FunctionCallContent

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>

* Update Azure.AI.OpenAI to 2.9.0-beta.1

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>

* Fix remaining GetResponsesClient(model) build failures for Azure.AI.OpenAI 2.9.0-beta.1

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>

* Address review feedback: remove redundant type checks in TestRequestAgent.cs and fix error message in AIAgentHostExecutor.cs

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>

* Update Azure.AI.Projects to 2.0.0-beta.2 with namespace migration

- Azure.AI.Projects 2.0.0-beta.1 → 2.0.0-beta.2
- Azure.AI.Projects.OpenAI → Azure.AI.Extensions.OpenAI (transitive)
- Agent types moved to Azure.AI.Projects.Agents namespace
- AgentRecord.Versions.Latest → AgentRecord.GetLatestVersion()
- OpenAPIFunctionDefinition → OpenApiFunctionDefinition
- BingCustomSearchToolParameters → BingCustomSearchToolOptions
- MemorySearchPreviewTool.UpdateDelay → UpdateDelayInSecs
- Azure.Identity 1.17.1 → 1.19.0
- Microsoft.Identity.Client.Extensions.Msal 4.78.0 → 4.83.1

Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>

* Fix remaining type renames for Azure.AI.Projects 2.0.0-beta.2

- BrowserAutomationToolParameters → BrowserAutomationToolOptions
- MemoryUpdateOptions.UpdateDelay stays as UpdateDelay (not renamed)
- WaitForMemoriesUpdateAsync parameter order: pollingInterval before options
- AIProjectAgentsOperations → AgentsClient

Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>

* Fix format errors and OpenTelemetry test for ME.AI 10.4.0

- Remove unused 'using Azure.AI.Extensions.OpenAI' and fix import ordering
  in Agent_With_AzureAIProject/Program.cs
- Update OpenTelemetryAgentTests: gen_ai.tool.definitions is now always
  emitted regardless of EnableSensitiveData per ME.AI 10.4.0 change
  (dotnet/extensions#7346). Tool definitions are not considered sensitive.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix GetRepoFolder() to work in git worktrees

Use 'workflow-samples' directory as repo root marker instead of '.git',
which fails in worktrees (.git is a file) and also matches too early
when a '.github' folder exists in subdirectories.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix formatting: remove unused usings and fix import ordering

dotnet format applied across 59 impacted projects. Primarily removes
unnecessary 'using Azure.AI.Projects' where Azure.AI.Projects.Agents
provides all needed types, and fixes import ordering per editorconfig.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Disable AzureAIAgentsPersistent integration tests (#4769)

Azure.AI.Agents.Persistent 1.2.0-beta.9 references McpServerToolApprovalResponseContent
which was removed in ME.AI 10.4.0 (renamed to ToolApprovalResponseContent), causing
TypeLoadException at runtime. Mark all 6 test classes with IntegrationDisabled trait
until Persistent ships a version targeting ME.AI 10.4.0+.

Upstream fix: https://github.com/Azure/azure-sdk-for-net/pull/56929

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add README with compatibility note for AzureAI.Persistent (#4769)

Documents that Azure.AI.Agents.Persistent 1.2.0-beta.9 is only compatible
with ME.AI ≤10.3.0 and OpenAI ≤2.8.0 due to type renames in ME.AI 10.4.0.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix file encoding: restore UTF-8 BOM on Persistent test files

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Mark AzureAI.Persistent as IsPackable=false (#4769)

Prevent shipping until Azure.AI.Agents.Persistent targets ME.AI 10.4.0+.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Moving IsPackable after import

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>
2026-03-20 14:29:29 +00:00

280 lines
11 KiB
C#

// 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/>
[SendsMessage(typeof(ExternalInputRequest))]
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 ToolApprovalRequestContent(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(jsonDocument.RootElement.GetListTypeFromJson()),
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);
}
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;
}
}