Skip to content

.NET: [Bug]: HandoffMessagesFilter.FilterMessages NullReferenceException with non-unique CallIds (Gemini/Vertex AI) #5244

Closed

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's AsChatClient())
  • 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

  1. TriageAgent calls handoff_to_1 to route to a specialist
  2. The specialist completes its work and calls handoff_to_1 to return to TriageAgent
  3. When the filter processes the conversation history for the next agent, it encounters handoff_to_1 as a CallId twice — once from each direction
  4. Crash: NullReferenceException at HandoffAgentExecutor.cs line ~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:

  1. The filter processes the first handoff_to_1 FunctionCallContent (from TriageAgent → Specialist) and adds it to filteringCandidates with FunctionCallResultLocation = null
  2. Later, the filter encounters the second handoff_to_1 FunctionCallContent (from Specialist → TriageAgent)
  3. TryGetValue finds the existing entry from step 1 and enters the else branch
  4. The else branch assumes the existing entry was created by a FunctionResultContent (which sets FunctionCallResultLocation) — but it was actually created by a FunctionCallContent (which does NOT set FunctionCallResultLocation)
  5. candidateState.FunctionCallResultLocation!.Value dereferences null → 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:

  1. Guard the null dereference: Check candidateState.FunctionCallResultLocation.HasValue before accessing .Value
  2. Use a list of candidates per CallId: Replace Dictionary<string, FilterCandidateState> with Dictionary<string, List<FilterCandidateState>> to handle non-unique CallIds
  3. Generate synthetic unique CallIds: If FunctionCallContent.CallId matches 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).

Activity

  1. added
    bugUsage: [Issues], Target: all issues (Legacy, prefer issue type: bug)
    .NETUsage: [Issues, PRs], Target: .Net
    on Apr 14, 2026
  2. removed
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Apr 14, 2026
  3. moved this from In Progress to Planned in Agent Frameworkon Apr 14, 2026
  4. moved this from Planned to In Progress in Agent Frameworkon Apr 14, 2026
  5. moved this from In Progress to In Review in Agent Frameworkon Apr 20, 2026
  6. moved this from In Review to Done in Agent Frameworkon Apr 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

.NETUsage: [Issues, PRs], Target: .NetbugUsage: [Issues], Target: all issues (Legacy, prefer issue type: bug)

Type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions