Repository navigation
Description
Description
Summary
HandoffMessagesFilter.FilterMessages throws a NullReferenceException when using the default HandoffOnly filtering behaviour with Google Gemini / Vertex AI models in a bidirectional handoff topology. The root cause is that Gemini's API has no concept of function call IDs, leading to non-unique CallId values that collide in the filter's internal dictionary.
Environment
- Microsoft.Agents.AI.Workflows: 1.1.0
- LLM Provider: Google Vertex AI (Gemini 2.5 Flash)
- IChatClient adapter:
Microsoft.Extensions.AI(via Semantic Kernel'sAsChatClient()) - Runtime: .NET 10.0
- OS: macOS (ARM64)
Reproduction
Topology
A bidirectional handoff between a TriageAgent and specialist agents:
AgentWorkflowBuilder
.CreateHandoffBuilderWith(triageAgent)
.WithHandoffs(triageAgent, specialists) // Triage → Specialists
.WithHandoffs(specialists, triageAgent) // Specialists → Triage (return path)
// Default: HandoffToolCallFilteringBehavior.HandoffOnly
.Build();Steps to reproduce
- TriageAgent calls
handoff_to_1to route to a specialist - The specialist completes its work and calls
handoff_to_1to return to TriageAgent - When the filter processes the conversation history for the next agent, it encounters
handoff_to_1as aCallIdtwice — once from each direction - Crash:
NullReferenceExceptionatHandoffAgentExecutor.csline ~105:
(int messageIndex, int contentIndex) = candidateState.FunctionCallResultLocation!.Value; // Throws
Exception
System.NullReferenceException: Object reference not set to an instance of an object.
at Microsoft.Agents.AI.Workflows.Specialized.HandoffMessagesFilter.FilterMessages(List`1 messages)
at Microsoft.Agents.AI.Workflows.Specialized.HandoffAgentExecutor.HandleAsync(...)
The NRE is subsequently serialized by the framework, which triggers a secondary error:
System.NotSupportedException: Serialization and deserialization of 'System.Reflection.MethodBase' instances is not supported.
Path: $.TargetSite
This secondary error is misleading — the actual bug is the NRE in FilterMessages.
Root Cause Analysis
1. Gemini has no function call IDs
The Gemini API does not provide call IDs for function calls (unlike OpenAI's call_abc123 pattern). The Semantic Kernel Google connector explicitly handles this:
Microsoft.SemanticKernel.Connectors.Google — GeminiChatMessageContent.cs:
// In PopulateFunctionCallContentItems():
new FunctionCallContent(
functionName: functionCall.FunctionName,
pluginName: null,
id: null, // <-- Gemini doesn't provide call IDs
arguments: ...
)GeminiFunctionCallingHelper.cs:
new FunctionResultContent(
functionName: result.FunctionName,
pluginName: null,
callId: null, // <-- No call ID available
result: ...
)2. The IChatClient adapter uses function name as CallId
When converting between Microsoft.SemanticKernel types and Microsoft.Extensions.AI types, the IChatClient adapter populates CallId with the function name when the original id/callId is null. This means every call to handoff_to_1 gets CallId = "handoff_to_1" regardless of which agent made the call.
3. The filter assumes CallId uniqueness
In HandoffMessagesFilter.FilterMessages, a Dictionary<string, FilterCandidateState> maps CallId → FilterCandidateState to match FunctionCallContent entries with their corresponding FunctionResultContent entries.
The crash occurs in the HandoffOnly code path (lines ~83-105):
// Processing a FunctionCallContent for a handoff function:
if (!filteringCandidates.TryGetValue(fcc.CallId, out FilterCandidateState? candidateState))
{
// First time seeing this CallId — add it as a new candidate
filteringCandidates[fcc.CallId] = new FilterCandidateState(fcc.CallId)
{
IsHandoffFunction = true,
};
}
else
{
// CallId already exists — assumes it was from a FunctionResultContent (out-of-order)
candidateState.IsHandoffFunction = true;
(int messageIndex, int contentIndex) = candidateState.FunctionCallResultLocation!.Value; // 💥 NRE HERE
// ... removes the previously-added FunctionResultContent
}What happens:
- The filter processes the first
handoff_to_1FunctionCallContent(from TriageAgent → Specialist) and adds it tofilteringCandidateswithFunctionCallResultLocation = null - Later, the filter encounters the second
handoff_to_1FunctionCallContent(from Specialist → TriageAgent) TryGetValuefinds the existing entry from step 1 and enters theelsebranch- The
elsebranch assumes the existing entry was created by aFunctionResultContent(which setsFunctionCallResultLocation) — but it was actually created by aFunctionCallContent(which does NOT setFunctionCallResultLocation) candidateState.FunctionCallResultLocation!.Valuedereferencesnull→ NRE
Debugger confirmation
Inspecting candidateState at the crash site:
{
"FunctionCallResultLocation": null,
"CallId": "handoff_to_1",
"IsHandoffFunction": true
}Workaround
Use HandoffToolCallFilteringBehavior.All to bypass the buggy HandoffOnly filter entirely:
AgentWorkflowBuilder
.CreateHandoffBuilderWith(triageAgent)
.WithHandoffs(triageAgent, specialists)
.WithHandoffs(specialists, triageAgent)
.WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior.All) // Workaround
.Build();Code Sample
Error Messages / Stack Traces
System.NotSupportedException: Serialization and deserialization of 'System.Reflection.MethodBase' instances is not supported.
Path: $.TargetSite
^
Caused by a Null reference exception.Package Versions
Microsoft.Agents.AI.Workflows: 1.1.0
.NET Version
No response
Additional Context
Suggested Fix
The else branch in FilterMessages needs to handle the case where a FilterCandidateState already exists from a previous FunctionCallContent (not just from a FunctionResultContent). When two FunctionCallContent entries share the same CallId, the filter should not assume FunctionCallResultLocation has been set.
Possible approaches:
- Guard the null dereference: Check
candidateState.FunctionCallResultLocation.HasValuebefore accessing.Value - Use a list of candidates per CallId: Replace
Dictionary<string, FilterCandidateState>withDictionary<string, List<FilterCandidateState>>to handle non-unique CallIds - Generate synthetic unique CallIds: If
FunctionCallContent.CallIdmatches a previously-seen CallId, append a disambiguating suffix (e.g.,handoff_to_1_2) before adding to the dictionary
Impact
This bug affects any LLM provider that does not generate unique function call IDs when used with bidirectional (or multi-hop) handoff topologies. Currently known affected:
- Google Gemini (via Vertex AI and Google AI) — explicitly sets
id: null/callId: null - Potentially any provider where the IChatClient adapter falls back to using function name as CallId
The bug does not manifest with OpenAI models because they generate unique call IDs (e.g., call_abc123).
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity