Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 41d6c61

Browse files
authored
.NET fix: Synthesized Handoff FunctionResult is never sent to agent (#5718)
* test: Split out Handoff Orchestration tests * fix: Synthesized Handoff FunctionResult is never sent to agent When we receive a handoff request from the agent, we need to service it outside of the Agent Loop to terminate the loop. What this means is that we take ownership of terminating the call by feeding the result back into the agent on a subsequent invocation. When we refactored Handoff to support HITL and make use of AgentSession, we inadvertantly removed this step, causing subsequent invocations to the Handoff agent to fail (first works, but breaks the state). The fix is to be more precise about the agent's bookmark when concatenating the result of agent invocation to the shared conversation history. * test: Add unit tests for Handoff FunctionCall/Result matching fix
1 parent dfc3079 commit 41d6c61

4 files changed

Lines changed: 1304 additions & 922 deletions

File tree

dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs

Lines changed: 43 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,33 @@ await this._sharedStateRef.InvokeWithStateAsync(
266266
sharedState.Conversation.AddMessages(incomingMessages);
267267
}
268268

269-
newConversationBookmark = sharedState.Conversation.AddMessages(result.Response.Messages);
269+
if (result.IsHandoffRequested)
270+
{
271+
int preHandoffMessageCount = result.Response.Messages.Count - 1;
272+
newConversationBookmark = sharedState.Conversation.AddMessages(result.Response.Messages.Take(preHandoffMessageCount));
273+
274+
// The following message contains the Handoff FunctionCallResult which should be added to the conversation history with
275+
// the caveat that we need to get it back next time _this_ agent is invoked because we need to feed the FunctionCallResult
276+
// back to the agent. So ignore the bookmark update.
277+
ChatMessage handoffCallResultMessage = result.Response.Messages[preHandoffMessageCount];
278+
279+
if (handoffCallResultMessage.Role != ChatRole.Tool)
280+
{
281+
throw new InvalidOperationException("The last message in a handoff response must be a Tool message containing the Handoff FunctionCallResult.");
282+
}
283+
284+
if (handoffCallResultMessage.Contents.Count != 1 ||
285+
handoffCallResultMessage.Contents[0] is not FunctionResultContent)
286+
{
287+
throw new InvalidOperationException("The Tool message in a handoff response must contain exactly one content item of type FunctionResultContent.");
288+
}
289+
290+
_ = sharedState.Conversation.AddMessage(handoffCallResultMessage);
291+
}
292+
else
293+
{
294+
newConversationBookmark = sharedState.Conversation.AddMessages(result.Response.Messages);
295+
}
270296

271297
return new ValueTask();
272298
},
@@ -376,39 +402,28 @@ private async ValueTask<AgentInvocationResult> InvokeAgentAsync(IEnumerable<Chat
376402
List<AgentResponseUpdate> updates = [];
377403
List<FunctionCallContent> candidateRequests = [];
378404

379-
await this.InvokeWithStateAsync(
380-
async (state, ctx, ct) =>
381-
{
382-
this._session ??= await this._agent.CreateSessionAsync(ct).ConfigureAwait(false);
383-
384-
IAsyncEnumerable<AgentResponseUpdate> agentStream =
385-
this._agent.RunStreamingAsync(messages,
386-
this._session,
387-
options: this._agentOptions,
388-
cancellationToken: ct);
405+
this._session ??= await this._agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
389406

390-
await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false))
391-
{
392-
await AddUpdateAsync(update, ct).ConfigureAwait(false);
407+
IAsyncEnumerable<AgentResponseUpdate> agentStream =
408+
this._agent.RunStreamingAsync(messages, this._session, this._agentOptions, cancellationToken);
393409

394-
collector.ProcessAgentResponseUpdate(update, CollectHandoffRequestsFilter);
410+
await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false))
411+
{
412+
await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false);
395413

396-
bool CollectHandoffRequestsFilter(FunctionCallContent candidateHandoffRequest)
397-
{
398-
bool isHandoffRequest = this._handoffFunctionNames.Contains(candidateHandoffRequest.Name);
399-
if (isHandoffRequest)
400-
{
401-
candidateRequests.Add(candidateHandoffRequest);
402-
}
414+
collector.ProcessAgentResponseUpdate(update, CollectHandoffRequestsFilter);
403415

404-
return !isHandoffRequest;
405-
}
416+
bool CollectHandoffRequestsFilter(FunctionCallContent candidateHandoffRequest)
417+
{
418+
bool isHandoffRequest = this._handoffFunctionNames.Contains(candidateHandoffRequest.Name);
419+
if (isHandoffRequest)
420+
{
421+
candidateRequests.Add(candidateHandoffRequest);
406422
}
407423

408-
return state;
409-
},
410-
context,
411-
cancellationToken: cancellationToken).ConfigureAwait(false);
424+
return !isHandoffRequest;
425+
}
426+
}
412427

413428
if (candidateRequests.Count > 1)
414429
{

dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/Magentic/StreamingToolCallResultPairMatcher.cs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,22 @@
33
using System;
44
using System.Collections.Generic;
55
using System.Diagnostics.CodeAnalysis;
6+
using System.Linq;
67
using Microsoft.Extensions.AI;
78

89
namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic;
910

1011
internal sealed class StreamingToolCallResultPairMatcher
1112
{
12-
private enum CallType
13+
internal enum CallType
1314
{
1415
Function,
1516
McpServerTool
1617
}
1718

1819
private record CallSummaryKey(CallType Type, string CallId);
1920

20-
private struct ToolCallSummary(CallType callType, string callId, string name)
21+
internal struct ToolCallSummary(CallType callType, string callId, string name)
2122
{
2223
public CallType CallType => callType;
2324

@@ -28,6 +29,12 @@ private struct ToolCallSummary(CallType callType, string callId, string name)
2829

2930
private readonly Dictionary<CallSummaryKey, ToolCallSummary> _callSummaries = new();
3031

32+
public bool HasUnmatchedCalls => this._callSummaries.Count > 0;
33+
34+
public IEnumerable<ToolCallSummary> UnmatchedCalls => this.HasUnmatchedCalls
35+
? this._callSummaries.Values.ToList()
36+
: [];
37+
3138
private void Collect(CallType callType, string callId, string name, string callContentTypeName, string resultContentTypeName)
3239
{
3340
CallSummaryKey key = new(callType, callId);

0 commit comments

Comments
 (0)