Skip to content

Commit f082fcb

Browse files
committed
fix: Duplicate CallIds cause Handoff Message Filtering to fail
Some providers, e.g. Gemini, do not use the CallId mechanism to disambiguate simultaneous function calls. This can result in message lists containing multiple turn to fail to filter properly. The fix is to take advantage of the expectation that Handoff Orchestration is a "single-speaker" flow, which only has a single active AIAgent per "turn" and an agent's turn is not finished until all outstanding function calls are finished. This allows us to expect that any ambiguous-CallId FunctionCallContent are either in separate turns or will have had a response before the next issued call with the same Id.
1 parent 60af59b commit f082fcb

File tree

3 files changed

+170
-89
lines changed

3 files changed

+170
-89
lines changed

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -426,7 +426,7 @@ await AddUpdateAsync(
426426
{
427427
AgentId = this._agent.Id,
428428
AuthorName = this._agent.Name ?? this._agent.Id,
429-
Contents = [new FunctionResultContent(handoffRequest.CallId, "Transferred.")],
429+
Contents = [CreateHandoffResult(handoffRequest.CallId)],
430430
CreatedAt = DateTimeOffset.UtcNow,
431431
MessageId = Guid.NewGuid().ToString("N"),
432432
Role = ChatRole.Tool,
@@ -459,4 +459,6 @@ ValueTask AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellat
459459
? this._handoffFunctionToAgentId.TryGetValue(requestedHandoff, out string? targetId) ? targetId : null
460460
: null;
461461
}
462+
463+
internal static FunctionResultContent CreateHandoffResult(string requestCallId) => new(requestCallId, "Transferred.");
462464
}

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

Lines changed: 52 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
using System;
44
using System.Collections.Generic;
55
using System.Diagnostics.CodeAnalysis;
6-
using System.Linq;
76
using Microsoft.Extensions.AI;
87

98
namespace Microsoft.Agents.AI.Workflows.Specialized;
@@ -31,113 +30,78 @@ public IEnumerable<ChatMessage> FilterMessages(IEnumerable<ChatMessage> messages
3130
return messages;
3231
}
3332

34-
Dictionary<string, FilterCandidateState> filteringCandidates = new();
35-
List<ChatMessage> filteredMessages = [];
36-
HashSet<int> messagesToRemove = [];
33+
HashSet<string> filteredCallsWithoutResponses = new();
34+
List<ChatMessage> retainedMessages = [];
35+
36+
bool filterAllToolCalls = this._filteringBehavior == HandoffToolCallFilteringBehavior.All;
37+
38+
// The logic of filtering is fairly straightforward: We are only interested in FunctionCallContent and FunctionResponseContent.
39+
// We are going to assume that Handoff operates as follows:
40+
// * Each agent is only taking one turn at a time
41+
// * Each agent is taking a turn alone
42+
//
43+
// In the case of certain providers, like Gemini (see microsoft/agent-framework #5244), we will see the function call name as the
44+
// call id as well, so we may see multiple calls with the same call id, and assume that the call is terminated before another
45+
// "CallId-less" FCC is issued. We also need to rely on the idea that FRC follows their corresponding FCC in the message stream.
46+
// (This changes the previous behaviour where FRC could arrive earlier, and relies on strict ordering).
47+
//
48+
// The benefit of expecting all the AIContent to be strictly ordered is that we never need to reach back into a post-filtered
49+
// content to retroactively remove it, or to try to inject it back into the middle of a Message that has already been processed.
3750

38-
bool filterHandoffOnly = this._filteringBehavior == HandoffToolCallFilteringBehavior.HandoffOnly;
3951
foreach (ChatMessage unfilteredMessage in messages)
4052
{
41-
ChatMessage filteredMessage = unfilteredMessage.Clone();
53+
if (unfilteredMessage.Contents is null || unfilteredMessage.Contents.Count == 0)
54+
{
55+
retainedMessages.Add(unfilteredMessage);
56+
continue;
57+
}
4258

43-
// .Clone() is shallow, so we cannot modify the contents of the cloned message in place.
44-
List<AIContent> contents = [];
45-
contents.Capacity = unfilteredMessage.Contents?.Count ?? 0;
46-
filteredMessage.Contents = contents;
59+
// We may need to filter out a subset of the message's content, but we won't know until we iterate through it. Create a new list
60+
// of AIContent which we will stuff into a clone of the message if we need to filter out any content.
61+
List<AIContent> retainedContents = new(capacity: unfilteredMessage.Contents.Count);
4762

48-
// Because this runs after the role changes from assistant to user for the target agent, we cannot rely on tool calls
49-
// originating only from messages with the Assistant role. Instead, we need to inspect the contents of all non-Tool (result)
50-
// FunctionCallContent.
51-
if (unfilteredMessage.Role != ChatRole.Tool)
63+
foreach (AIContent content in unfilteredMessage.Contents)
5264
{
53-
for (int i = 0; i < unfilteredMessage.Contents!.Count; i++)
65+
if (content is FunctionCallContent fcc
66+
&& (filterAllToolCalls || IsHandoffFunctionName(fcc.Name)))
5467
{
55-
AIContent content = unfilteredMessage.Contents[i];
56-
if (content is not FunctionCallContent fcc || (filterHandoffOnly && !IsHandoffFunctionName(fcc.Name)))
57-
{
58-
filteredMessage.Contents.Add(content);
59-
60-
// Track non-handoff function calls so their tool results are preserved in HandoffOnly mode
61-
if (filterHandoffOnly && content is FunctionCallContent nonHandoffFcc)
62-
{
63-
filteringCandidates[nonHandoffFcc.CallId] = new FilterCandidateState(nonHandoffFcc.CallId)
64-
{
65-
IsHandoffFunction = false,
66-
};
67-
}
68-
}
69-
else if (filterHandoffOnly)
68+
// If we already have an unmatched candidate with the same CallId, that means we have two FCCs in a row without an FRC,
69+
// which violates our assumption of strict ordering.
70+
if (!filteredCallsWithoutResponses.Add(fcc.CallId))
7071
{
71-
if (!filteringCandidates.TryGetValue(fcc.CallId, out FilterCandidateState? candidateState))
72-
{
73-
filteringCandidates[fcc.CallId] = new FilterCandidateState(fcc.CallId)
74-
{
75-
IsHandoffFunction = true,
76-
};
77-
}
78-
else
79-
{
80-
candidateState.IsHandoffFunction = true;
81-
(int messageIndex, int contentIndex) = candidateState.FunctionCallResultLocation!.Value;
82-
ChatMessage messageToFilter = filteredMessages[messageIndex];
83-
messageToFilter.Contents.RemoveAt(contentIndex);
84-
if (messageToFilter.Contents.Count == 0)
85-
{
86-
messagesToRemove.Add(messageIndex);
87-
}
88-
}
72+
throw new InvalidOperationException($"Duplicate FunctionCallContent with CallId '{fcc.CallId}' without corresponding FunctionResultContent.");
8973
}
90-
else
91-
{
92-
// All mode: strip all FunctionCallContent
93-
}
94-
}
95-
}
96-
else
97-
{
98-
if (!filterHandoffOnly)
99-
{
74+
75+
// If we are filtering all tool calls, or this is a handoff call (and we are not filtering None, already checked), then
76+
// filter this FCC
10077
continue;
10178
}
102-
103-
for (int i = 0; i < unfilteredMessage.Contents!.Count; i++)
79+
else if (content is FunctionResultContent frc)
10480
{
105-
AIContent content = unfilteredMessage.Contents[i];
106-
if (content is not FunctionResultContent frc
107-
|| (filteringCandidates.TryGetValue(frc.CallId, out FilterCandidateState? candidateState)
108-
&& candidateState.IsHandoffFunction is false))
109-
{
110-
// Either this is not a function result content, so we should let it through, or it is a FRC that
111-
// we know is not related to a handoff call. In either case, we should include it.
112-
filteredMessage.Contents.Add(content);
113-
}
114-
else if (candidateState is null)
81+
// We rely on the corresponding FCC to have already been processed, so check if it is in the candidate dictionary.
82+
// If it is, we can filter out the FRC, but we need to remove the candidate from the dictionary, since a future FCC can
83+
// come in with the same CallId, and should be considered a new call that may need to be filtered.
84+
if (filteredCallsWithoutResponses.Remove(frc.CallId))
11585
{
116-
// We haven't seen the corresponding function call yet, so add it as a candidate to be filtered later
117-
filteringCandidates[frc.CallId] = new FilterCandidateState(frc.CallId)
118-
{
119-
FunctionCallResultLocation = (filteredMessages.Count, filteredMessage.Contents.Count),
120-
};
86+
continue;
12187
}
122-
// else we have seen the corresponding function call and it is a handoff, so we should filter it out.
12388
}
89+
90+
// FCC/FRC, but not filtered, or neither FCC nor FRC: this should not be filtered out
91+
retainedContents.Add(content);
12492
}
12593

126-
if (filteredMessage.Contents.Count > 0)
94+
if (retainedContents.Count == 0)
12795
{
128-
filteredMessages.Add(filteredMessage);
96+
// message was fully filtered, skip it
97+
continue;
12998
}
130-
}
13199

132-
return filteredMessages.Where((_, index) => !messagesToRemove.Contains(index));
133-
}
134-
135-
private class FilterCandidateState(string callId)
136-
{
137-
public (int MessageIndex, int ContentIndex)? FunctionCallResultLocation { get; set; }
138-
139-
public string CallId => callId;
100+
ChatMessage filteredMessage = unfilteredMessage.Clone();
101+
filteredMessage.Contents = retainedContents;
102+
retainedMessages.Add(filteredMessage);
103+
}
140104

141-
public bool? IsHandoffFunction { get; set; }
105+
return retainedMessages;
142106
}
143107
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System;
4+
using System.Collections.Generic;
5+
using FluentAssertions;
6+
using Microsoft.Agents.AI.Workflows.Specialized;
7+
using Microsoft.Extensions.AI;
8+
9+
namespace Microsoft.Agents.AI.Workflows.UnitTests;
10+
11+
public class HandoffMessageFilterTests
12+
{
13+
private List<ChatMessage> CreateTestMessages(bool firstAgentUsesCallId, bool secondAgentUsesCallId, HandoffToolCallFilteringBehavior filter = HandoffToolCallFilteringBehavior.None)
14+
{
15+
FunctionCallContent handoffRequest1 = CreateHandoffCall(1, firstAgentUsesCallId);
16+
FunctionResultContent handoffResponse1 = CreateHandoffResponse(handoffRequest1);
17+
18+
FunctionCallContent toolCall = CreateToolCall(secondAgentUsesCallId);
19+
FunctionResultContent toolResponse = CreateToolResponse(toolCall);
20+
21+
// Approvals come from the function call middleware over ChatClient, so we can expect there to be a RequestId (not that we
22+
// care, because we do not filter approval content)
23+
ToolApprovalRequestContent toolApproval = new(Guid.NewGuid().ToString("N"), toolCall);
24+
ToolApprovalResponseContent toolApprovalResponse = new(toolApproval.RequestId, true, toolCall);
25+
26+
FunctionCallContent handoffRequest2 = CreateHandoffCall(1, secondAgentUsesCallId);
27+
FunctionResultContent handoffResponse2 = CreateHandoffResponse(handoffRequest2);
28+
29+
List<ChatMessage> result = [new(ChatRole.User, "Hello")];
30+
31+
// Agent 1 turn
32+
result.Add(new(ChatRole.Assistant, "Hello! What do you want help with today?"));
33+
result.Add(new(ChatRole.User, "Please explain temperature"));
34+
35+
// Unless we are filtering none, we expect the handoff call to be filtered out, so we add it conditionally
36+
if (filter == HandoffToolCallFilteringBehavior.None)
37+
{
38+
result.Add(new(ChatRole.Assistant, [handoffRequest1]));
39+
result.Add(new(ChatRole.Tool, [handoffResponse1]));
40+
}
41+
42+
// Agent 2 turn
43+
44+
// Tool approvals are never filtered, so we add them unconditionally
45+
result.Add(new(ChatRole.Assistant, [toolApproval]));
46+
result.Add(new(ChatRole.User, [toolApprovalResponse]));
47+
48+
// Unless we are filtering all, we expect the tool call to be retained, so we add it conditionally
49+
if (filter != HandoffToolCallFilteringBehavior.All)
50+
{
51+
result.Add(new(ChatRole.Assistant, [toolCall]));
52+
result.Add(new(ChatRole.Tool, [toolResponse]));
53+
}
54+
55+
result.Add(new(ChatRole.Assistant, "Temperature is a measure of the average kinetic energy of the particles in a substance."));
56+
57+
if (filter == HandoffToolCallFilteringBehavior.None)
58+
{
59+
result.Add(new(ChatRole.Assistant, [handoffRequest2]));
60+
result.Add(new(ChatRole.Tool, [handoffResponse2]));
61+
}
62+
63+
return result;
64+
}
65+
66+
private static FunctionCallContent CreateHandoffCall(int id, bool useCallId)
67+
{
68+
string callName = $"{HandoffWorkflowBuilder.FunctionPrefix}{id}";
69+
string callId = useCallId ? Guid.NewGuid().ToString("N") : callName;
70+
71+
return new FunctionCallContent(callId, callName);
72+
}
73+
74+
private static FunctionResultContent CreateHandoffResponse(FunctionCallContent call)
75+
=> HandoffAgentExecutor.CreateHandoffResult(call.CallId);
76+
77+
private static FunctionCallContent CreateToolCall(bool useCallId)
78+
{
79+
const string CallName = "ToolFunction";
80+
string callId = useCallId ? Guid.NewGuid().ToString("N") : CallName;
81+
82+
return new FunctionCallContent(callId, CallName);
83+
}
84+
85+
private static FunctionResultContent CreateToolResponse(FunctionCallContent call)
86+
=> new(call.CallId, new object());
87+
88+
[Theory]
89+
[InlineData(true, true, HandoffToolCallFilteringBehavior.None)]
90+
[InlineData(true, false, HandoffToolCallFilteringBehavior.None)]
91+
[InlineData(false, true, HandoffToolCallFilteringBehavior.None)]
92+
[InlineData(false, false, HandoffToolCallFilteringBehavior.None)]
93+
[InlineData(true, true, HandoffToolCallFilteringBehavior.HandoffOnly)]
94+
[InlineData(true, false, HandoffToolCallFilteringBehavior.HandoffOnly)]
95+
[InlineData(false, true, HandoffToolCallFilteringBehavior.HandoffOnly)]
96+
[InlineData(false, false, HandoffToolCallFilteringBehavior.HandoffOnly)]
97+
[InlineData(true, true, HandoffToolCallFilteringBehavior.All)]
98+
[InlineData(true, false, HandoffToolCallFilteringBehavior.All)]
99+
[InlineData(false, true, HandoffToolCallFilteringBehavior.All)]
100+
[InlineData(false, false, HandoffToolCallFilteringBehavior.All)]
101+
public void Test_HandoffMessageFilter_FiltersOnlyExpectedMessages(bool firstAgentUsesCallId, bool secondAgentUsesCallId, HandoffToolCallFilteringBehavior behavior)
102+
{
103+
// Arrange
104+
List<ChatMessage> messages = this.CreateTestMessages(firstAgentUsesCallId, secondAgentUsesCallId);
105+
List<ChatMessage> expected = this.CreateTestMessages(firstAgentUsesCallId, secondAgentUsesCallId, behavior);
106+
107+
HandoffMessagesFilter filter = new(behavior);
108+
109+
// Act
110+
IEnumerable<ChatMessage> filteredMessages = filter.FilterMessages(messages);
111+
112+
// Assert
113+
filteredMessages.Should().BeEquivalentTo(expected);
114+
}
115+
}

0 commit comments

Comments
 (0)