1212
1313namespace 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>
16161internal 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