From 1baf4af4d8130a80ed317db0bf464badd6089619 Mon Sep 17 00:00:00 2001 From: alliscode <25218250+alliscode@users.noreply.github.com> Date: Fri, 15 May 2026 17:19:50 -0700 Subject: [PATCH] Hosted-agent HITL: persist session across previous_response_id chains; run approved local AIFunctions Two regressions hit declarative workflows that use require_approval=true when the client chains turns via previous_response_id (no conversation_id): 1. AgentFrameworkResponseHandler keyed the AgentSession store solely on conversation_id, so when only previous_response_id was present the StateBag (which holds ToolApprovalIdMap) was discarded after each turn. The next turn then threw 'No approval mapping recorded for wire id ...' in InputConverter.ConvertMcpApprovalResponse. Fix: fall back to previous_response_id on load and to context.ResponseId on save so the response-id chain becomes a valid session key. Conversation id remains preferred when present. 2. InvokeFunctionToolExecutor.CaptureResponseAsync only acted on FunctionResultContent. In the hosted Foundry path the approval response arrives as a ToolApprovalResponseContent with no FunctionResultContent, so the local AIFunction never ran and downstream PropertyPath/SendActivity consumers (e.g. {Local.RefundResult}) saw empty values. Fix: when no FunctionResultContent matches but an approved ToolApprovalResponseContent does, look up the registered AIFunction by name on agentProvider.Functions and invoke it with the evaluated arguments, surfacing the result through the existing assignment path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AgentFrameworkResponseHandler.cs | 34 +++++++++--- .../ObjectModel/InvokeFunctionToolExecutor.cs | 54 +++++++++++++++++++ 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index c88fbd88c6..22090451f7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -56,13 +56,24 @@ public class AgentFrameworkResponseHandler : ResponseHandler var agent = this.ResolveAgent(request); var sessionStore = this.ResolveSessionStore(request); - // 2. Load or create a new session from the interaction + // 2. Load or create a new session from the interaction. + // + // The session can be keyed by either: + // - conversation_id (used by clients that explicitly thread a conversation), or + // - previous_response_id (used by clients that chain via the Responses API) + // + // Without this fallback, clients that rely on previous_response_id chaining lose + // session state (StateBag — including HITL tool-approval id mappings) between turns. var sessionConversationId = request.GetConversationId(); + var previousResponseId = request.PreviousResponseId; + var sessionLoadKey = !string.IsNullOrWhiteSpace(sessionConversationId) + ? sessionConversationId + : previousResponseId; var chatClientAgent = agent.GetService(); - AgentSession? session = !string.IsNullOrWhiteSpace(sessionConversationId) - ? await sessionStore.GetSessionAsync(agent, sessionConversationId, cancellationToken).ConfigureAwait(false) + AgentSession? session = !string.IsNullOrWhiteSpace(sessionLoadKey) + ? await sessionStore.GetSessionAsync(agent, sessionLoadKey, cancellationToken).ConfigureAwait(false) : chatClientAgent is not null ? await chatClientAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false) : await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); @@ -81,7 +92,7 @@ public class AgentFrameworkResponseHandler : ResponseHandler // (e.g. resuming a workflow paused at an external-input port), the workflow's // checkpointed state already contains the prior turns' messages — replaying history // would re-drive completed actions and break HITL resume semantics. - var isResume = !string.IsNullOrWhiteSpace(sessionConversationId) + var isResume = !string.IsNullOrWhiteSpace(sessionLoadKey) && session?.StateBag?.Count > 0; if (!isResume) { @@ -284,10 +295,19 @@ public class AgentFrameworkResponseHandler : ResponseHandler { await enumerator.DisposeAsync().ConfigureAwait(false); - // Persist session after streaming completes (successful or not) - if (session is not null && !string.IsNullOrWhiteSpace(sessionConversationId)) + // Persist session after streaming completes (successful or not). + // + // Save key precedence mirrors the load logic above: + // - conversation_id is stable across all turns of the same conversation. + // - Otherwise we save under this turn's response_id so the next request — + // which arrives with previous_response_id == this response_id — can find it. + var sessionSaveKey = !string.IsNullOrWhiteSpace(sessionConversationId) + ? sessionConversationId + : context.ResponseId; + + if (session is not null && !string.IsNullOrWhiteSpace(sessionSaveKey)) { - await sessionStore.SaveSessionAsync(agent, sessionConversationId, session, cancellationToken).ConfigureAwait(false); + await sessionStore.SaveSessionAsync(agent, sessionSaveKey, session, cancellationToken).ConfigureAwait(false); } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs index baa6f9c6b8..b4845abcd5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs @@ -103,6 +103,24 @@ internal sealed class InvokeFunctionToolExecutor( FunctionResultContent? matchingResult = functionResults .FirstOrDefault(r => r.CallId == this.Id); + // When the caller approved an approval-required function call but didn't execute it + // locally (the hosted Foundry scenario, where mcp_approval_response is converted to a + // ToolApprovalResponseContent only), invoke the registered AIFunction here so that the + // declarative workflow can capture the result and continue (e.g. for downstream + // SendActivity/PropertyPath consumers like {Local.Result}). + if (matchingResult is null) + { + ToolApprovalResponseContent? approval = response.Messages + .SelectMany(m => m.Contents) + .OfType() + .FirstOrDefault(r => r.RequestId == this.Id); + + if (approval is { Approved: true }) + { + matchingResult = await this.InvokeRegisteredFunctionAsync(cancellationToken).ConfigureAwait(false); + } + } + if (matchingResult is not null) { // Store the result in output variable @@ -241,6 +259,42 @@ internal sealed class InvokeFunctionToolExecutor( return conversationIdValue.Length == 0 ? null : conversationIdValue; } + private async ValueTask InvokeRegisteredFunctionAsync(CancellationToken cancellationToken) + { + string functionName = this.GetFunctionName(); + AIFunction? function = agentProvider.Functions?.FirstOrDefault( + f => string.Equals(f.Name, functionName, System.StringComparison.Ordinal)); + + if (function is null) + { + return new FunctionResultContent( + this.Id, + $"Function '{functionName}' is not registered with the agent provider."); + } + + Dictionary? arguments = this.GetArguments(); + AIFunctionArguments? functionArguments = arguments is null ? null : new AIFunctionArguments(arguments); + + object? result; + try + { + result = await function.InvokeAsync(functionArguments, cancellationToken).ConfigureAwait(false); + } + catch (System.Exception ex) + { + return new FunctionResultContent(this.Id, $"Function '{functionName}' invocation failed: {ex.Message}"); + } + + string serialized = result switch + { + null => string.Empty, + string s => s, + _ => result.ToString() ?? string.Empty, + }; + + return new FunctionResultContent(this.Id, serialized); + } + private bool GetRequireApproval() { if (this.Model.RequireApproval is null)