Skip to content

Commit 2cb4137

Browse files
authored
.NET: Remove FunctionCalls and Tool Messages from Handoff passed messages (#3811)
* Fix handoff orchestration not passing user message to handoff target agent (#3161) Filter out internal handoff function call and tool result messages before passing conversation history to the target agent's LLM. These messages confused the model into ignoring the original user question. * Add handoff tool call filtering behavior and enhance workflow builder - Introduced HandoffToolCallFilteringBehavior enum to specify filtering behavior for tool call contents in handoff workflows. - Updated HandoffsWorkflowBuilder to support customizable handoff instructions and tool call filtering behavior. - Enhanced HandoffAgentExecutor to utilize new filtering options for improved message handling during agent handoffs. * Enhance handoff message filtering logic and add unit tests for filtering behaviors * Refactor HandoffMessagesFilter to remove unused handoff function names and enhance filtering logic for non-handoff function calls * Refactor HandoffMessagesFilter to streamline FilterCandidateState initialization and improve clarity * Refactor HandoffMessagesFilter to improve filtering logic and add integration tests for handoff workflows * fix: HandoffAgentExecutor tests
1 parent 40d3a06 commit 2cb4137

4 files changed

Lines changed: 468 additions & 7 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using Microsoft.Extensions.AI;
4+
5+
namespace Microsoft.Agents.AI.Workflows;
6+
7+
/// <summary>
8+
/// Specifies the behavior for filtering <see cref="FunctionCallContent"/> and <see cref="ChatRole.Tool"/> contents from
9+
/// <see cref="ChatMessage"/>s flowing through a handoff workflow. This can be used to prevent agents from seeing external
10+
/// tool calls.
11+
/// </summary>
12+
public enum HandoffToolCallFilteringBehavior
13+
{
14+
/// <summary>
15+
/// Do not filter <see cref="FunctionCallContent"/> and <see cref="ChatRole.Tool"/> contents.
16+
/// </summary>
17+
None,
18+
19+
/// <summary>
20+
/// Filter only handoff-related <see cref="FunctionCallContent"/> and <see cref="ChatRole.Tool"/> contents.
21+
/// </summary>
22+
HandoffOnly,
23+
24+
/// <summary>
25+
/// Filter all <see cref="FunctionCallContent"/> and <see cref="ChatRole.Tool"/> contents.
26+
/// </summary>
27+
All
28+
}

dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.Collections.Generic;
44
using System.Linq;
55
using Microsoft.Agents.AI.Workflows.Specialized;
6+
using Microsoft.Extensions.AI;
67
using Microsoft.Shared.Diagnostics;
78

89
namespace Microsoft.Agents.AI.Workflows;
@@ -16,6 +17,7 @@ public sealed class HandoffsWorkflowBuilder
1617
private readonly AIAgent _initialAgent;
1718
private readonly Dictionary<AIAgent, HashSet<HandoffTarget>> _targets = [];
1819
private readonly HashSet<AIAgent> _allAgents = new(AIAgentIDEqualityComparer.Instance);
20+
private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly;
1921

2022
/// <summary>
2123
/// Initializes a new instance of the <see cref="HandoffsWorkflowBuilder"/> class with no handoff relationships.
@@ -34,14 +36,38 @@ internal HandoffsWorkflowBuilder(AIAgent initialAgent)
3436
/// By default, simple instructions are included. This may be set to <see langword="null"/> to avoid including
3537
/// any additional instructions, or may be customized to provide more specific guidance.
3638
/// </remarks>
37-
public string? HandoffInstructions { get; set; } =
38-
$"""
39+
public string? HandoffInstructions { get; private set; } = DefaultHandoffInstructions;
40+
41+
private const string DefaultHandoffInstructions =
42+
$"""
3943
You are one agent in a multi-agent system. You can hand off the conversation to another agent if appropriate. Handoffs are achieved
4044
by calling a handoff function, named in the form `{FunctionPrefix}<agent_id>`; the description of the function provides details on the
4145
target agent of that handoff. Handoffs between agents are handled seamlessly in the background; never mention or narrate these handoffs
4246
in your conversation with the user.
4347
""";
4448

49+
/// <summary>
50+
/// Sets additional instructions to provide to an agent that has handoffs about how and when to
51+
/// perform them.
52+
/// </summary>
53+
/// <param name="instructions">The instructions to provide, or <see langword="null"/> to restore the default instructions.</param>
54+
public HandoffsWorkflowBuilder WithHandoffInstructions(string? instructions)
55+
{
56+
this.HandoffInstructions = instructions ?? DefaultHandoffInstructions;
57+
return this;
58+
}
59+
60+
/// <summary>
61+
/// Sets the behavior for filtering <see cref="FunctionCallContent"/> and <see cref="ChatRole.Tool"/> contents from
62+
/// <see cref="ChatMessage"/>s flowing through the handoff workflow. Defaults to <see cref="HandoffToolCallFilteringBehavior.HandoffOnly"/>.
63+
/// </summary>
64+
/// <param name="behavior">The filtering behavior to apply.</param>
65+
public HandoffsWorkflowBuilder WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior behavior)
66+
{
67+
this._toolCallFilteringBehavior = behavior;
68+
return this;
69+
}
70+
4571
/// <summary>
4672
/// Adds handoff relationships from a source agent to one or more target agents.
4773
/// </summary>
@@ -149,8 +175,10 @@ public Workflow Build()
149175
HandoffsEndExecutor end = new();
150176
WorkflowBuilder builder = new(start);
151177

178+
HandoffAgentExecutorOptions options = new(this.HandoffInstructions, this._toolCallFilteringBehavior);
179+
152180
// Create an AgentExecutor for each again.
153-
Dictionary<string, HandoffAgentExecutor> executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, this.HandoffInstructions));
181+
Dictionary<string, HandoffAgentExecutor> executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, options));
154182

155183
// Connect the start executor to the initial agent.
156184
builder.AddEdge(start, executors[this._initialAgent.Id]);

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

Lines changed: 158 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,155 @@
1212

1313
namespace Microsoft.Agents.AI.Workflows.Specialized;
1414

15+
internal sealed class HandoffAgentExecutorOptions
16+
{
17+
public HandoffAgentExecutorOptions(string? handoffInstructions, HandoffToolCallFilteringBehavior toolCallFilteringBehavior)
18+
{
19+
this.HandoffInstructions = handoffInstructions;
20+
this.ToolCallFilteringBehavior = toolCallFilteringBehavior;
21+
}
22+
23+
public string? HandoffInstructions { get; set; }
24+
25+
public HandoffToolCallFilteringBehavior ToolCallFilteringBehavior { get; set; } = HandoffToolCallFilteringBehavior.HandoffOnly;
26+
}
27+
28+
internal sealed class HandoffMessagesFilter
29+
{
30+
private readonly HandoffToolCallFilteringBehavior _filteringBehavior;
31+
32+
public HandoffMessagesFilter(HandoffToolCallFilteringBehavior filteringBehavior)
33+
{
34+
this._filteringBehavior = filteringBehavior;
35+
}
36+
37+
internal static bool IsHandoffFunctionName(string name)
38+
{
39+
return name.StartsWith(HandoffsWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal);
40+
}
41+
42+
public IEnumerable<ChatMessage> FilterMessages(List<ChatMessage> messages)
43+
{
44+
if (this._filteringBehavior == HandoffToolCallFilteringBehavior.None)
45+
{
46+
return messages;
47+
}
48+
49+
Dictionary<string, FilterCandidateState> filteringCandidates = new();
50+
List<ChatMessage> filteredMessages = [];
51+
HashSet<int> messagesToRemove = [];
52+
53+
bool filterHandoffOnly = this._filteringBehavior == HandoffToolCallFilteringBehavior.HandoffOnly;
54+
foreach (ChatMessage unfilteredMessage in messages)
55+
{
56+
ChatMessage filteredMessage = unfilteredMessage.Clone();
57+
58+
// .Clone() is shallow, so we cannot modify the contents of the cloned message in place.
59+
List<AIContent> contents = [];
60+
contents.Capacity = unfilteredMessage.Contents?.Count ?? 0;
61+
filteredMessage.Contents = contents;
62+
63+
// Because this runs after the role changes from assistant to user for the target agent, we cannot rely on tool calls
64+
// originating only from messages with the Assistant role. Instead, we need to inspect the contents of all non-Tool (result)
65+
// FunctionCallContent.
66+
if (unfilteredMessage.Role != ChatRole.Tool)
67+
{
68+
for (int i = 0; i < unfilteredMessage.Contents!.Count; i++)
69+
{
70+
AIContent content = unfilteredMessage.Contents[i];
71+
if (content is not FunctionCallContent fcc || (filterHandoffOnly && !IsHandoffFunctionName(fcc.Name)))
72+
{
73+
filteredMessage.Contents.Add(content);
74+
75+
// Track non-handoff function calls so their tool results are preserved in HandoffOnly mode
76+
if (filterHandoffOnly && content is FunctionCallContent nonHandoffFcc)
77+
{
78+
filteringCandidates[nonHandoffFcc.CallId] = new FilterCandidateState(nonHandoffFcc.CallId)
79+
{
80+
IsHandoffFunction = false,
81+
};
82+
}
83+
}
84+
else if (filterHandoffOnly)
85+
{
86+
if (!filteringCandidates.TryGetValue(fcc.CallId, out FilterCandidateState? candidateState))
87+
{
88+
filteringCandidates[fcc.CallId] = new FilterCandidateState(fcc.CallId)
89+
{
90+
IsHandoffFunction = true,
91+
};
92+
}
93+
else
94+
{
95+
candidateState.IsHandoffFunction = true;
96+
(int messageIndex, int contentIndex) = candidateState.FunctionCallResultLocation!.Value;
97+
ChatMessage messageToFilter = filteredMessages[messageIndex];
98+
messageToFilter.Contents.RemoveAt(contentIndex);
99+
if (messageToFilter.Contents.Count == 0)
100+
{
101+
messagesToRemove.Add(messageIndex);
102+
}
103+
}
104+
}
105+
else
106+
{
107+
// All mode: strip all FunctionCallContent
108+
}
109+
}
110+
}
111+
else
112+
{
113+
if (!filterHandoffOnly)
114+
{
115+
continue;
116+
}
117+
118+
for (int i = 0; i < unfilteredMessage.Contents!.Count; i++)
119+
{
120+
AIContent content = unfilteredMessage.Contents[i];
121+
if (content is not FunctionResultContent frc
122+
|| (filteringCandidates.TryGetValue(frc.CallId, out FilterCandidateState? candidateState)
123+
&& candidateState.IsHandoffFunction is false))
124+
{
125+
// Either this is not a function result content, so we should let it through, or it is a FRC that
126+
// we know is not related to a handoff call. In either case, we should include it.
127+
filteredMessage.Contents.Add(content);
128+
}
129+
else if (candidateState is null)
130+
{
131+
// We haven't seen the corresponding function call yet, so add it as a candidate to be filtered later
132+
filteringCandidates[frc.CallId] = new FilterCandidateState(frc.CallId)
133+
{
134+
FunctionCallResultLocation = (filteredMessages.Count, filteredMessage.Contents.Count),
135+
};
136+
}
137+
// else we have seen the corresponding function call and it is a handoff, so we should filter it out.
138+
}
139+
}
140+
141+
if (filteredMessage.Contents.Count > 0)
142+
{
143+
filteredMessages.Add(filteredMessage);
144+
}
145+
}
146+
147+
return filteredMessages.Where((_, index) => !messagesToRemove.Contains(index));
148+
}
149+
150+
private class FilterCandidateState(string callId)
151+
{
152+
public (int MessageIndex, int ContentIndex)? FunctionCallResultLocation { get; set; }
153+
154+
public string CallId => callId;
155+
156+
public bool? IsHandoffFunction { get; set; }
157+
}
158+
}
159+
15160
/// <summary>Executor used to represent an agent in a handoffs workflow, responding to <see cref="HandoffState"/> events.</summary>
16161
internal sealed class HandoffAgentExecutor(
17162
AIAgent agent,
18-
string? handoffInstructions) : Executor<HandoffState, HandoffState>(agent.GetDescriptiveId(), declareCrossRunShareable: true), IResettableExecutor
163+
HandoffAgentExecutorOptions options) : Executor<HandoffState, HandoffState>(agent.GetDescriptiveId(), declareCrossRunShareable: true), IResettableExecutor
19164
{
20165
private static readonly JsonElement s_handoffSchema = AIFunctionFactory.Create(
21166
([Description("The reason for the handoff")] string? reasonForHandoff) => { }).JsonSchema;
@@ -39,7 +184,7 @@ public void Initialize(
39184
ChatOptions = new()
40185
{
41186
AllowMultipleToolCalls = false,
42-
Instructions = handoffInstructions,
187+
Instructions = options.HandoffInstructions,
43188
Tools = [],
44189
},
45190
};
@@ -69,10 +214,19 @@ public override async ValueTask<HandoffState> HandleAsync(HandoffState message,
69214

70215
List<ChatMessage>? roleChanges = allMessages.ChangeAssistantToUserForOtherParticipants(this._agent.Name ?? this._agent.Id);
71216

72-
await foreach (var update in this._agent.RunStreamingAsync(allMessages,
217+
// If a handoff was invoked by a previous agent, filter out the handoff function
218+
// call and tool result messages before sending to the underlying agent. These
219+
// are internal workflow mechanics that confuse the target model into ignoring the
220+
// original user question.
221+
HandoffMessagesFilter handoffMessagesFilter = new(options.ToolCallFilteringBehavior);
222+
IEnumerable<ChatMessage> messagesForAgent = message.InvokedHandoff is not null
223+
? handoffMessagesFilter.FilterMessages(allMessages)
224+
: allMessages;
225+
226+
await foreach (var update in this._agent.RunStreamingAsync(messagesForAgent,
73227
options: this._agentOptions,
74228
cancellationToken: cancellationToken)
75-
.ConfigureAwait(false))
229+
.ConfigureAwait(false))
76230
{
77231
await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false);
78232

0 commit comments

Comments
 (0)