|
3 | 3 | using System; |
4 | 4 | using System.Collections.Generic; |
5 | 5 | using System.Diagnostics.CodeAnalysis; |
6 | | -using System.Linq; |
7 | 6 | using Microsoft.Extensions.AI; |
8 | 7 |
|
9 | 8 | namespace Microsoft.Agents.AI.Workflows.Specialized; |
@@ -31,113 +30,78 @@ public IEnumerable<ChatMessage> FilterMessages(IEnumerable<ChatMessage> messages |
31 | 30 | return messages; |
32 | 31 | } |
33 | 32 |
|
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. |
37 | 50 |
|
38 | | - bool filterHandoffOnly = this._filteringBehavior == HandoffToolCallFilteringBehavior.HandoffOnly; |
39 | 51 | foreach (ChatMessage unfilteredMessage in messages) |
40 | 52 | { |
41 | | - ChatMessage filteredMessage = unfilteredMessage.Clone(); |
| 53 | + if (unfilteredMessage.Contents is null || unfilteredMessage.Contents.Count == 0) |
| 54 | + { |
| 55 | + retainedMessages.Add(unfilteredMessage); |
| 56 | + continue; |
| 57 | + } |
42 | 58 |
|
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); |
47 | 62 |
|
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) |
52 | 64 | { |
53 | | - for (int i = 0; i < unfilteredMessage.Contents!.Count; i++) |
| 65 | + if (content is FunctionCallContent fcc |
| 66 | + && (filterAllToolCalls || IsHandoffFunctionName(fcc.Name))) |
54 | 67 | { |
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)) |
70 | 71 | { |
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."); |
89 | 73 | } |
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 |
100 | 77 | continue; |
101 | 78 | } |
102 | | - |
103 | | - for (int i = 0; i < unfilteredMessage.Contents!.Count; i++) |
| 79 | + else if (content is FunctionResultContent frc) |
104 | 80 | { |
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)) |
115 | 85 | { |
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; |
121 | 87 | } |
122 | | - // else we have seen the corresponding function call and it is a handoff, so we should filter it out. |
123 | 88 | } |
| 89 | + |
| 90 | + // FCC/FRC, but not filtered, or neither FCC nor FRC: this should not be filtered out |
| 91 | + retainedContents.Add(content); |
124 | 92 | } |
125 | 93 |
|
126 | | - if (filteredMessage.Contents.Count > 0) |
| 94 | + if (retainedContents.Count == 0) |
127 | 95 | { |
128 | | - filteredMessages.Add(filteredMessage); |
| 96 | + // message was fully filtered, skip it |
| 97 | + continue; |
129 | 98 | } |
130 | | - } |
131 | 99 |
|
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 | + } |
140 | 104 |
|
141 | | - public bool? IsHandoffFunction { get; set; } |
| 105 | + return retainedMessages; |
142 | 106 | } |
143 | 107 | } |
0 commit comments