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)