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>
This commit is contained in:
alliscode
2026-05-15 17:19:50 -07:00
Unverified
parent fbb4c867dd
commit 1baf4af4d8
2 changed files with 81 additions and 7 deletions
@@ -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<ChatClientAgent>();
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);
}
}
}
@@ -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<ToolApprovalResponseContent>()
.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<FunctionResultContent?> 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<string, object?>? 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)