-
Notifications
You must be signed in to change notification settings - Fork 1.6k
.NET: fix: Add session support for Handoff-hosted Agents #5280
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
lokitoth
merged 9 commits into
main
from
dev/dotnet_workflow/fix_handoff_agent_session_handling
Apr 17, 2026
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
2951748
fix: Add session support for Handoff-hosted Agents
lokitoth 0aeec63
fix: AgentSession checkpointing using AIAgent's Serialize/Deserialize…
lokitoth 661046b
fix: Thread safety issue in `MultiPartyConversation.AllMessages`
lokitoth 70d5aca
fix: Enable unwrapping of FunctionResultContent when ExternalRequest …
lokitoth e33029d
Merge branch 'main' into dev/dotnet_workflow/fix_handoff_agent_sessio…
lokitoth 6ef207e
Merge branch 'main' into dev/dotnet_workflow/fix_handoff_agent_sessio…
lokitoth 34d9838
Merge branch 'main' into dev/dotnet_workflow/fix_handoff_agent_sessio…
lokitoth 006b2ce
Merge branch 'main' into dev/dotnet_workflow/fix_handoff_agent_sessio…
lokitoth 30a0925
Merge branch 'main' into dev/dotnet_workflow/fix_handoff_agent_sessio…
lokitoth File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
365 changes: 166 additions & 199 deletions
365
dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
143 changes: 143 additions & 0 deletions
143
dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffMessagesFilter.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| // Copyright (c) Microsoft. All rights reserved. | ||
|
|
||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.Diagnostics.CodeAnalysis; | ||
| using System.Linq; | ||
| using Microsoft.Extensions.AI; | ||
|
|
||
| namespace Microsoft.Agents.AI.Workflows.Specialized; | ||
|
|
||
| [Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)] | ||
| internal sealed class HandoffMessagesFilter | ||
| { | ||
| private readonly HandoffToolCallFilteringBehavior _filteringBehavior; | ||
|
|
||
| public HandoffMessagesFilter(HandoffToolCallFilteringBehavior filteringBehavior) | ||
| { | ||
| this._filteringBehavior = filteringBehavior; | ||
| } | ||
|
|
||
| [Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)] | ||
| internal static bool IsHandoffFunctionName(string name) | ||
| { | ||
| return name.StartsWith(HandoffWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal); | ||
| } | ||
|
|
||
| public IEnumerable<ChatMessage> FilterMessages(IEnumerable<ChatMessage> messages) | ||
|
lokitoth marked this conversation as resolved.
|
||
| { | ||
| if (this._filteringBehavior == HandoffToolCallFilteringBehavior.None) | ||
| { | ||
| return messages; | ||
| } | ||
|
|
||
| Dictionary<string, FilterCandidateState> filteringCandidates = new(); | ||
| List<ChatMessage> filteredMessages = []; | ||
| HashSet<int> messagesToRemove = []; | ||
|
|
||
| bool filterHandoffOnly = this._filteringBehavior == HandoffToolCallFilteringBehavior.HandoffOnly; | ||
| foreach (ChatMessage unfilteredMessage in messages) | ||
| { | ||
| ChatMessage filteredMessage = unfilteredMessage.Clone(); | ||
|
|
||
| // .Clone() is shallow, so we cannot modify the contents of the cloned message in place. | ||
| List<AIContent> contents = []; | ||
| contents.Capacity = unfilteredMessage.Contents?.Count ?? 0; | ||
| filteredMessage.Contents = contents; | ||
|
|
||
| // Because this runs after the role changes from assistant to user for the target agent, we cannot rely on tool calls | ||
| // originating only from messages with the Assistant role. Instead, we need to inspect the contents of all non-Tool (result) | ||
| // FunctionCallContent. | ||
| if (unfilteredMessage.Role != ChatRole.Tool) | ||
| { | ||
| for (int i = 0; i < unfilteredMessage.Contents!.Count; i++) | ||
| { | ||
| AIContent content = unfilteredMessage.Contents[i]; | ||
| if (content is not FunctionCallContent fcc || (filterHandoffOnly && !IsHandoffFunctionName(fcc.Name))) | ||
| { | ||
| filteredMessage.Contents.Add(content); | ||
|
|
||
| // Track non-handoff function calls so their tool results are preserved in HandoffOnly mode | ||
| if (filterHandoffOnly && content is FunctionCallContent nonHandoffFcc) | ||
| { | ||
| filteringCandidates[nonHandoffFcc.CallId] = new FilterCandidateState(nonHandoffFcc.CallId) | ||
| { | ||
| IsHandoffFunction = false, | ||
| }; | ||
| } | ||
| } | ||
| else if (filterHandoffOnly) | ||
| { | ||
| if (!filteringCandidates.TryGetValue(fcc.CallId, out FilterCandidateState? candidateState)) | ||
| { | ||
| filteringCandidates[fcc.CallId] = new FilterCandidateState(fcc.CallId) | ||
| { | ||
| IsHandoffFunction = true, | ||
| }; | ||
| } | ||
| else | ||
| { | ||
| candidateState.IsHandoffFunction = true; | ||
| (int messageIndex, int contentIndex) = candidateState.FunctionCallResultLocation!.Value; | ||
| ChatMessage messageToFilter = filteredMessages[messageIndex]; | ||
| messageToFilter.Contents.RemoveAt(contentIndex); | ||
| if (messageToFilter.Contents.Count == 0) | ||
| { | ||
| messagesToRemove.Add(messageIndex); | ||
| } | ||
| } | ||
| } | ||
| else | ||
| { | ||
| // All mode: strip all FunctionCallContent | ||
| } | ||
| } | ||
| } | ||
| else | ||
| { | ||
| if (!filterHandoffOnly) | ||
| { | ||
| continue; | ||
| } | ||
|
|
||
| for (int i = 0; i < unfilteredMessage.Contents!.Count; i++) | ||
| { | ||
| AIContent content = unfilteredMessage.Contents[i]; | ||
| if (content is not FunctionResultContent frc | ||
| || (filteringCandidates.TryGetValue(frc.CallId, out FilterCandidateState? candidateState) | ||
| && candidateState.IsHandoffFunction is false)) | ||
| { | ||
| // Either this is not a function result content, so we should let it through, or it is a FRC that | ||
| // we know is not related to a handoff call. In either case, we should include it. | ||
| filteredMessage.Contents.Add(content); | ||
| } | ||
| else if (candidateState is null) | ||
| { | ||
| // We haven't seen the corresponding function call yet, so add it as a candidate to be filtered later | ||
| filteringCandidates[frc.CallId] = new FilterCandidateState(frc.CallId) | ||
| { | ||
| FunctionCallResultLocation = (filteredMessages.Count, filteredMessage.Contents.Count), | ||
|
lokitoth marked this conversation as resolved.
|
||
| }; | ||
| } | ||
| // else we have seen the corresponding function call and it is a handoff, so we should filter it out. | ||
| } | ||
| } | ||
|
|
||
| if (filteredMessage.Contents.Count > 0) | ||
| { | ||
| filteredMessages.Add(filteredMessage); | ||
| } | ||
| } | ||
|
|
||
| return filteredMessages.Where((_, index) => !messagesToRemove.Contains(index)); | ||
| } | ||
|
|
||
| private class FilterCandidateState(string callId) | ||
| { | ||
| public (int MessageIndex, int ContentIndex)? FunctionCallResultLocation { get; set; } | ||
|
|
||
| public string CallId => callId; | ||
|
|
||
| public bool? IsHandoffFunction { get; set; } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
4 changes: 0 additions & 4 deletions
4
dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,12 +1,8 @@ | ||
| // Copyright (c) Microsoft. All rights reserved. | ||
|
|
||
| using System.Collections.Generic; | ||
| using Microsoft.Extensions.AI; | ||
|
|
||
| namespace Microsoft.Agents.AI.Workflows.Specialized; | ||
|
|
||
| internal sealed record class HandoffState( | ||
| TurnToken TurnToken, | ||
| string? RequestedHandoffTargetAgentId, | ||
| List<ChatMessage> Messages, | ||
| string? PreviousAgentId = null); |
56 changes: 56 additions & 0 deletions
56
dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/MultiPartyConversation.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| // Copyright (c) Microsoft. All rights reserved. | ||
|
|
||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.Linq; | ||
| using Microsoft.Extensions.AI; | ||
|
|
||
| namespace Microsoft.Agents.AI.Workflows.Specialized; | ||
|
|
||
| internal sealed class MultiPartyConversation | ||
| { | ||
| private readonly List<ChatMessage> _history = []; | ||
| private readonly object _mutex = new(); | ||
|
|
||
| public List<ChatMessage> CloneAllMessages() | ||
| { | ||
| lock (this._mutex) | ||
| { | ||
| return this._history.ToList(); | ||
| } | ||
| } | ||
|
|
||
| public (ChatMessage[], int) CollectNewMessages(int bookmark) | ||
| { | ||
| lock (this._mutex) | ||
| { | ||
| int count = this._history.Count - bookmark; | ||
| if (count < 0) | ||
| { | ||
| throw new InvalidOperationException($"Bookmark value too large: {bookmark} vs count={count}"); | ||
| } | ||
|
|
||
| return (this._history.Skip(bookmark).ToArray(), this.CurrentBookmark); | ||
| } | ||
| } | ||
|
lokitoth marked this conversation as resolved.
|
||
|
|
||
| private int CurrentBookmark => this._history.Count; | ||
|
|
||
| public int AddMessages(IEnumerable<ChatMessage> messages) | ||
| { | ||
| lock (this._mutex) | ||
| { | ||
| this._history.AddRange(messages); | ||
| return this.CurrentBookmark; | ||
| } | ||
| } | ||
|
|
||
| public int AddMessage(ChatMessage message) | ||
| { | ||
| lock (this._mutex) | ||
| { | ||
| this._history.Add(message); | ||
| return this.CurrentBookmark; | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.