Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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;

Copy link
Copy Markdown
Contributor

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.

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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

completedRequests never consumes or removes entries, so an ordered FunctionCall → FunctionResult → FunctionCall sequence with the same composite key rejects the second call. The handoff filtering contract explicitly supports providers such as Gemini reusing a call ID after its prior result, but this path externalizes the later valid handoff and pauses instead of routing it. Please model completion as closing only the current call occurrence so a later call with the same ID, producer, and response can become a new candidate.

&& this._handoffFunctionNames.Contains(candidateHandoffRequest.Name);
if (isHandoffRequest)
{
candidateRequests.Add((candidateHandoffRequest, update.ResponseId));
candidateRequests.Add((candidateHandoffRequest, effectiveAgentId, effectiveResponseId));
}

return !isHandoffRequest;
Expand All @@ -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(
Expand Down Expand Up @@ -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.");
Expand Down
Loading
Loading