-
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| 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) | ||||||||||||||||||||||||
| { | ||||||||||||||||||||||||
| 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), | ||||||||||||||||||||||||
|
Comment on lines
+116
to
+119
|
||||||||||||||||||||||||
| // 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), | |
| // We haven't seen the corresponding function call yet, so preserve the tool result in the filtered | |
| // message and record its exact location so it can be removed later if the call is classified as a | |
| // handoff. | |
| filteredMessage.Contents.Add(content); | |
| filteringCandidates[frc.CallId] = new FilterCandidateState(frc.CallId) | |
| { | |
| FunctionCallResultLocation = (filteredMessages.Count, filteredMessage.Contents.Count - 1), |
| 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); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,49 @@ | ||||||||||||||||||||||||
| // Copyright (c) Microsoft. All rights reserved. | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| 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 IReadOnlyList<ChatMessage> AllMessages => this._history; | ||||||||||||||||||||||||
|
||||||||||||||||||||||||
| public IReadOnlyList<ChatMessage> AllMessages => this._history; | |
| public IReadOnlyList<ChatMessage> AllMessages | |
| { | |
| get | |
| { | |
| lock (this._mutex) | |
| { | |
| return this._history.ToArray(); | |
| } | |
| } | |
| } |
Copilot
AI
Apr 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If bookmark is greater than _history.Count, count becomes negative and the method silently returns no messages. That can hide state corruption and cause missed messages. Consider explicitly handling out-of-range bookmarks (e.g., clamp bookmark into [0..Count] or throw an exception) so incorrect bookmarks are not silently ignored.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ConfigureProtocol()declares.YieldsOutput<List<ChatMessage>>(), but this yieldsIReadOnlyList<ChatMessage>. Even if the underlying instance is aList<ChatMessage>, the static type mismatch is confusing and may break consumers expecting an actualList<ChatMessage>. Consider yielding a concreteList<ChatMessage>snapshot (also avoids concurrent enumeration issues from sharing the live backing list).