-
Notifications
You must be signed in to change notification settings - Fork 2.4k
.NET: Validate handoff request lifecycle before routing #8855
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -429,7 +429,13 @@ private async ValueTask<AgentInvocationResult> InvokeAgentAsync(IEnumerable<Chat | |
| AIAgentUnservicedRequestsCollector collector = new(this._userInputHandler, this._functionCallHandler); | ||
| string? requestedHandoff = null; | ||
| List<AgentResponseUpdate> updates = []; | ||
| List<(FunctionCallContent Request, string? ResponseId)> candidateRequests = []; | ||
| List<(FunctionCallContent Request, string ProducerId, string? ResponseId)> candidateRequests = []; | ||
| HashSet<(string CallId, string ProducerId, string? ResponseId)> completedRequests = []; | ||
| bool hasMessageScope = false; | ||
| string? scopedMessageId = null; | ||
| string? scopedResponseId = null; | ||
| ChatRole? scopedRole = null; | ||
| string? scopedAgentId = null; | ||
|
|
||
| this._session ??= await this._agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); | ||
|
|
||
|
|
@@ -440,14 +446,72 @@ private async ValueTask<AgentInvocationResult> InvokeAgentAsync(IEnumerable<Chat | |
| { | ||
| await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false); | ||
|
|
||
| collector.ProcessAgentResponseUpdate(update, CollectHandoffRequestsFilter); | ||
| bool isSameMessageScope = | ||
| hasMessageScope | ||
| && AreCompatibleMessageIds(scopedMessageId, update.MessageId) | ||
| && !(scopedResponseId is not null | ||
| && update.ResponseId is not null | ||
| && !string.Equals(scopedResponseId, update.ResponseId, StringComparison.Ordinal)); | ||
|
|
||
| if (!isSameMessageScope) | ||
| { | ||
| // Optional metadata is inherited only within one logical message. Resetting on an | ||
| // identifiable message or response transition prevents foreign provenance or an | ||
| // assistant role from leaking into the next message. | ||
| scopedRole = null; | ||
| scopedAgentId = null; | ||
| scopedMessageId = update.MessageId; | ||
| scopedResponseId = update.ResponseId; | ||
| hasMessageScope = true; | ||
| } | ||
| else | ||
| { | ||
| // Contiguous updates with no message or response ID form one invocation-local | ||
| // anonymous scope. Null remains an exact correlation value, never a global wildcard. | ||
| scopedMessageId ??= update.MessageId; | ||
| scopedResponseId ??= update.ResponseId; | ||
| } | ||
|
|
||
| scopedRole = update.Role ?? scopedRole; | ||
| scopedAgentId = update.AgentId ?? scopedAgentId ?? this._agent.Id; | ||
|
|
||
| ChatRole? effectiveRole = scopedRole; | ||
| string effectiveAgentId = scopedAgentId; | ||
| string? effectiveResponseId = update.ResponseId ?? scopedResponseId; | ||
|
|
||
| // Process content in stream order so a provider-owned tool completion cancels the | ||
| // corresponding candidate before routing. Producer and response identity are part of | ||
| // the key because provider streams may reuse bare call IDs across concurrent responses. | ||
| foreach (AIContent content in update.Contents) | ||
| { | ||
| if (content is FunctionResultContent functionResult) | ||
| { | ||
| (string CallId, string ProducerId, string? ResponseId) completion = | ||
| (functionResult.CallId, effectiveAgentId, effectiveResponseId); | ||
| _ = completedRequests.Add(completion); | ||
| _ = candidateRequests.RemoveAll( | ||
| candidate => string.Equals(candidate.Request.CallId, completion.CallId, StringComparison.Ordinal) | ||
| && string.Equals(candidate.ProducerId, completion.ProducerId, StringComparison.Ordinal) | ||
| && string.Equals(candidate.ResponseId, completion.ResponseId, StringComparison.Ordinal)); | ||
| } | ||
|
|
||
| collector.ProcessAIContents([content], CollectHandoffRequestsFilter); | ||
| } | ||
|
|
||
| bool CollectHandoffRequestsFilter(FunctionCallContent candidateHandoffRequest) | ||
| { | ||
| bool isHandoffRequest = this._handoffFunctionNames.Contains(candidateHandoffRequest.Name); | ||
| // A handoff is an unresolved assistant request for a declaration owned by this | ||
| // executor. Other roles, other agents, malformed IDs, and already-completed calls | ||
| // are provider lifecycle events rather than application routing decisions. | ||
| bool isHandoffRequest = | ||
| effectiveRole == ChatRole.Assistant | ||
| && string.Equals(effectiveAgentId, this._agent.Id, StringComparison.Ordinal) | ||
| && !string.IsNullOrWhiteSpace(candidateHandoffRequest.CallId) | ||
| && !completedRequests.Contains((candidateHandoffRequest.CallId, effectiveAgentId, effectiveResponseId)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| && this._handoffFunctionNames.Contains(candidateHandoffRequest.Name); | ||
| if (isHandoffRequest) | ||
| { | ||
| candidateRequests.Add((candidateHandoffRequest, update.ResponseId)); | ||
| candidateRequests.Add((candidateHandoffRequest, effectiveAgentId, effectiveResponseId)); | ||
| } | ||
|
|
||
| return !isHandoffRequest; | ||
|
|
@@ -462,7 +526,7 @@ bool CollectHandoffRequestsFilter(FunctionCallContent candidateHandoffRequest) | |
|
|
||
| if (candidateRequests.Count > 0) | ||
| { | ||
| (FunctionCallContent handoffRequest, string? handoffResponseId) = candidateRequests[candidateRequests.Count - 1]; | ||
| (FunctionCallContent handoffRequest, _, string? handoffResponseId) = candidateRequests[candidateRequests.Count - 1]; | ||
| requestedHandoff = handoffRequest.Name; | ||
|
|
||
| await AddUpdateAsync( | ||
|
|
@@ -503,6 +567,11 @@ ValueTask AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellat | |
| => requestedHandoff != null | ||
| ? this._handoffFunctionToAgentId.TryGetValue(requestedHandoff, out string? targetId) ? targetId : null | ||
| : null; | ||
|
|
||
| static bool AreCompatibleMessageIds(string? establishedId, string? updateId) | ||
| => establishedId is null | ||
| ? updateId is null | ||
| : updateId is null || string.Equals(establishedId, updateId, StringComparison.Ordinal); | ||
| } | ||
|
|
||
| internal static FunctionResultContent CreateHandoffResult(string requestCallId) => new(requestCallId, "Transferred."); | ||
|
|
||
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.
When a handoff has a response ID but its matching tool result starts a new message and omits the optional
ResponseId, this reset changes the completion key to(call, agent, null), so it no longer removes the(call, agent, response)candidate. The already-completed handoff is then routed and receives a synthetic transfer result. Please retain response correlation across a message transition when the update does not explicitly provide a different response ID, while still isolating explicitly different responses.