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 @@ -14,8 +14,9 @@ internal sealed class AIAgentUnservicedRequestsCollector(AIContentExternalHandle
{
private readonly Dictionary<string, ToolApprovalRequestContent> _userInputRequests = [];
private readonly Dictionary<string, FunctionCallContent> _functionCalls = [];
private readonly List<string> _displacedRequestIds = [];

public Task SubmitAsync(IWorkflowContext context, CancellationToken cancellationToken)
public async Task SubmitAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
Task userInputTask = userInputHandler != null && this._userInputRequests.Count > 0
? userInputHandler.ProcessRequestContentsAsync(this._userInputRequests, context, cancellationToken)
Expand All @@ -25,7 +26,17 @@ public Task SubmitAsync(IWorkflowContext context, CancellationToken cancellation
? functionCallHandler.ProcessRequestContentsAsync(this._functionCalls, context, cancellationToken)
: Task.CompletedTask;

return Task.WhenAll(userInputTask, functionCallTask);
await Task.WhenAll(userInputTask, functionCallTask).ConfigureAwait(false);

if (this._displacedRequestIds.Count > 0)
{
// A different request reusing an outstanding ID cannot be serviced alongside the one already
// recorded under it, so report the ones that were dropped rather than losing them silently.
await context.AddEventAsync(
new WorkflowWarningEvent(
$"Ignored request content reusing an outstanding request ID ([{string.Join(", ", this._displacedRequestIds)}])."),
cancellationToken).ConfigureAwait(false);
}
}

public void ProcessAgentResponseUpdate(AgentResponseUpdate update, Func<FunctionCallContent, bool>? functionCallFilter = null)
Expand All @@ -34,19 +45,34 @@ public void ProcessAgentResponseUpdate(AgentResponseUpdate update, Func<Function
public void ProcessAgentResponse(AgentResponse response)
=> this.ProcessAIContents(response.Messages.SelectMany(message => message.Contents));

/// <summary>
/// Records the requests these contents leave unserviced, and clears the ones they answer.
/// </summary>
/// <remarks>
/// The first content seen for a request ID is the one kept, matching
/// <see cref="AIContentExternalHandler{TRequestContent, TResponseContent}"/>, which treats a repeat as an
/// idempotent re-emission, and <c>ApprovalResponseBindingChatClient</c>, which binds a response against the
/// first request recorded for the ID. A later content that is not the same request is reported by
/// <see cref="SubmitAsync"/>.
/// </remarks>
public void ProcessAIContents(IEnumerable<AIContent> contents, Func<FunctionCallContent, bool>? functionCallFilter = null)
{
foreach (AIContent content in contents)
{
if (content is ToolApprovalRequestContent userInputRequest)
{
if (this._userInputRequests.ContainsKey(userInputRequest.RequestId))
if (this._userInputRequests.TryGetValue(userInputRequest.RequestId, out ToolApprovalRequestContent? recordedRequest))
{
throw new InvalidOperationException($"ToolApprovalRequestContent with duplicate RequestId: {userInputRequest.RequestId}");
// The approval's ID is derived from the call it guards, so a different call under the
// same ID is a different request rather than a re-emission of this one.
this.NoteDisplacedRequest(
userInputRequest.RequestId,
string.Equals(recordedRequest.ToolCall.CallId, userInputRequest.ToolCall.CallId, StringComparison.Ordinal));
}
else
{
this._userInputRequests.Add(userInputRequest.RequestId, userInputRequest);
}

// It is an error to simultaneously have multiple outstanding user input requests with the same ID.
this._userInputRequests.Add(userInputRequest.RequestId, userInputRequest);
}
else if (content is ToolApprovalResponseContent userInputResponse)
{
Expand All @@ -61,12 +87,18 @@ public void ProcessAIContents(IEnumerable<AIContent> contents, Func<FunctionCall
// possibility 2: this will not be handled inline by the agent abstraction
if (functionCallFilter == null || functionCallFilter(functionCall))
{
if (this._functionCalls.ContainsKey(functionCall.CallId))
if (this._functionCalls.TryGetValue(functionCall.CallId, out FunctionCallContent? recordedCall))
{
throw new InvalidOperationException($"FunctionCallContent with duplicate CallId: {functionCall.CallId}");
// A same-named call under one ID is taken to be a re-emission. Comparing arguments
// instead would report a legitimate re-emission whose arguments were rebuilt.
this.NoteDisplacedRequest(
functionCall.CallId,
string.Equals(recordedCall.Name, functionCall.Name, StringComparison.Ordinal));
}
else
{
this._functionCalls.Add(functionCall.CallId, functionCall);
}

this._functionCalls.Add(functionCall.CallId, functionCall);
}
}
else if (content is FunctionResultContent functionResult)
Expand All @@ -75,4 +107,12 @@ public void ProcessAIContents(IEnumerable<AIContent> contents, Func<FunctionCall
}
}
}

private void NoteDisplacedRequest(string requestId, bool isSameRequest)
{
if (!isSameRequest && !this._displacedRequestIds.Contains(requestId, StringComparer.Ordinal))
{
this._displacedRequestIds.Add(requestId);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,13 @@ private async ValueTask<AgentInvocationResult> InvokeAgentAsync(IEnumerable<Chat
bool CollectHandoffRequestsFilter(FunctionCallContent candidateHandoffRequest)
{
bool isHandoffRequest = this._handoffFunctionNames.Contains(candidateHandoffRequest.Name);
if (isHandoffRequest)

// A stream that re-emits one handoff call must not read as two competing handoffs. The name is
// compared alongside the ID so that two different targets sharing an ID stay visible below.
if (isHandoffRequest
&& !candidateRequests.Any(candidate =>
string.Equals(candidate.Request.CallId, candidateHandoffRequest.CallId, StringComparison.Ordinal)
&& string.Equals(candidate.Request.Name, candidateHandoffRequest.Name, StringComparison.Ordinal)))
{
candidateRequests.Add((candidateHandoffRequest, update.ResponseId));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,83 @@ List<object> ExtractAndValidateRequestContents<TRequest>() where TRequest : AICo
lastResponseEvent.Response.Text.Should().Be("Done");
}

[Fact]
public async Task Test_AgentHostExecutor_ReusedApprovalRequestIdIsReportedAsync()
{
// Arrange
const string RequestId = "shared-request-id";
TestRunContext testContext = new();
RequestEmittingAgent agent = new(
[
new ToolApprovalRequestContent(RequestId, new McpServerToolCallContent("first-call", "firstTool", "http://localhost")),
new ToolApprovalRequestContent(RequestId, new McpServerToolCallContent("second-call", "secondTool", "http://localhost")),
]);
AIAgentHostExecutor executor = new(agent, new() { InterceptUserInputRequests = false, EmitAgentUpdateEvents = true });
testContext.ConfigureExecutor(executor);

// Act
await executor.TakeTurnAsync(new(), testContext.BindWorkflowContext(executor.Id));

// Assert
WorkflowWarningEvent warning = testContext.Events.OfType<WorkflowWarningEvent>().Should().ContainSingle().Which;
warning.Data.Should().BeOfType<string>().Which.Should().Contain(RequestId);

ToolApprovalRequestContent raised =
testContext.ExternalRequests.Should().ContainSingle().Which.Data.As<ToolApprovalRequestContent>()
.Should().NotBeNull().And.Subject.As<ToolApprovalRequestContent>();
raised.ToolCall.CallId.Should().Be("first-call", "the first request recorded for an ID is the one raised");
}

[Fact]
public async Task Test_AgentHostExecutor_ReusedCallIdIsReportedAsync()
{
// Arrange
const string CallId = "shared-call-id";
TestRunContext testContext = new();
RequestEmittingAgent agent = new(
[
new FunctionCallContent(CallId, "firstFunction"),
new FunctionCallContent(CallId, "secondFunction"),
]);
AIAgentHostExecutor executor = new(agent, new() { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true });
testContext.ConfigureExecutor(executor);

// Act
await executor.TakeTurnAsync(new(), testContext.BindWorkflowContext(executor.Id));

// Assert
WorkflowWarningEvent warning = testContext.Events.OfType<WorkflowWarningEvent>().Should().ContainSingle().Which;
warning.Data.Should().BeOfType<string>().Which.Should().Contain(CallId);

FunctionCallContent raised =
testContext.ExternalRequests.Should().ContainSingle().Which.Data.As<FunctionCallContent>()
.Should().NotBeNull().And.Subject.As<FunctionCallContent>();
raised.Name.Should().Be("firstFunction", "the first request recorded for an ID is the one raised");
}

[Fact]
public async Task Test_AgentHostExecutor_ReEmittedRequestIsNotReportedAsync()
{
// Arrange
const string CallId = "re-emitted-call-id";
TestRunContext testContext = new();
RequestEmittingAgent agent = new(
[
new FunctionCallContent(CallId, "sameFunction"),
new FunctionCallContent(CallId, "sameFunction"),
]);
AIAgentHostExecutor executor = new(agent, new() { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true });
testContext.ConfigureExecutor(executor);

// Act
await executor.TakeTurnAsync(new(), testContext.BindWorkflowContext(executor.Id));

// Assert
testContext.Events.OfType<WorkflowWarningEvent>()
.Should().BeEmpty("the same call repeated across updates is one request, not a displaced one");
testContext.ExternalRequests.Should().ContainSingle();
}

#region FilterForwardableMessages tests

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
Expand Down Expand Up @@ -176,6 +177,101 @@ public async Task Test_HandoffAgentExecutor_ComposesWithHITLSubworkflowAsync()
testContext.BindWorkflowContext(executor.Id));
}

/// <summary>
/// An agent that emits the workflow's handoff call once per update, modelling a stream that spreads one call
/// over several updates.
/// </summary>
private sealed class RepeatingHandoffAgent(int emissionCount, bool varyTarget = false) : AIAgent
{
private sealed class Session : AgentSession
{
public Session() { }
}

protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
=> new(new Session());

protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> new(new Session());

protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> default;

protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> this.RunStreamingAsync(messages, session, options, cancellationToken).ToAgentResponseAsync(cancellationToken);

protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
List<string> handoffFunctionNames =
[.. (options as ChatClientAgentRunOptions)?.ChatOptions?.Tools?
.Where(tool => tool.Name.StartsWith("handoff_to_", StringComparison.Ordinal))
.Select(tool => tool.Name) ?? []];

if (handoffFunctionNames.Count == 0)
{
throw new InvalidOperationException("No handoff tool was offered to the agent.");
}

for (int emission = 0; emission < emissionCount; emission++)
{
// Every emission reuses one call ID; only the target varies, and only when asked.
string handoffFunctionName = handoffFunctionNames[varyTarget ? emission % handoffFunctionNames.Count : 0];
yield return new AgentResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("handoff-call", handoffFunctionName)]);
}
}
}

[Fact]
public async Task Test_HandoffAgentExecutor_RepeatedHandoffCallIsNotTreatedAsDuplicateAsync()
{
// Arrange
AIAgent handoffAgent = new RepeatingHandoffAgent(emissionCount: 2);
HandoffTarget handoff = new(new TestEchoAgent());

HandoffAgentExecutorOptions options = new(handoffInstructions: null,
emitAgentResponseEvents: false,
emitAgentResponseUpdateEvents: true,
HandoffToolCallFilteringBehavior.None);
HandoffAgentExecutor executor = new(handoffAgent, [handoff], options);

TestRunContext runContext = await PrepareHandoffSharedStateAsync();
runContext.ConfigureExecutor(executor);
IWorkflowContext testContext = runContext.BindWorkflowContext(executor.Id);

// Act
await executor.HandleAsync(new HandoffState(new(true), null), testContext);

// Assert
runContext.Events.OfType<WorkflowWarningEvent>()
.Should().BeEmpty("one handoff call re-emitted across updates is a single handoff request");
}

[Fact]
public async Task Test_HandoffAgentExecutor_DifferentTargetsSharingACallIdStillWarnAsync()
{
// Arrange
AIAgent handoffAgent = new RepeatingHandoffAgent(emissionCount: 2, varyTarget: true);
HandoffTarget firstHandoff = new(new TestEchoAgent("firstTarget"));
HandoffTarget secondHandoff = new(new TestEchoAgent("secondTarget"));

HandoffAgentExecutorOptions options = new(handoffInstructions: null,
emitAgentResponseEvents: false,
emitAgentResponseUpdateEvents: true,
HandoffToolCallFilteringBehavior.None);
HandoffAgentExecutor executor = new(handoffAgent, [firstHandoff, secondHandoff], options);

TestRunContext runContext = await PrepareHandoffSharedStateAsync();
runContext.ConfigureExecutor(executor);
IWorkflowContext testContext = runContext.BindWorkflowContext(executor.Id);

// Act
await executor.HandleAsync(new HandoffState(new(true), null), testContext);

// Assert
runContext.Events.OfType<WorkflowWarningEvent>()
.Should().ContainSingle("two different handoff targets remain two competing handoffs even under one call ID");
}

[Fact]
public async Task Test_HandoffAgentExecutor_PreservesExistingInstructionsAndToolsAsync()
{
Expand Down
Loading
Loading