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 @@ -269,7 +269,19 @@ await this.EnsureSessionAsync(context, cancellationToken).ConfigureAwait(false),
generatedMessageRole = null;
}

await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false);
// Approval requests this executor raises through its request port are surfaced to the caller
// with the workflow-facing request ID, so the agent-local copy is not emitted as output too.
// An approval the agent answers itself is never raised, so it goes out ahead of its answer.
foreach (AgentResponseUpdate answered in collector.TakeApprovalsAnsweredBy(update))
{
await context.YieldOutputAsync(answered, cancellationToken).ConfigureAwait(false);
}

if (collector.FilterExternallyRaisedApprovals(update) is AgentResponseUpdate emittedUpdate)
{
await context.YieldOutputAsync(emittedUpdate, cancellationToken).ConfigureAwait(false);
}

collector.ProcessAgentResponseUpdate(update);
updates.Add(update);
}
Expand All @@ -289,7 +301,7 @@ await this.EnsureSessionAsync(context, cancellationToken).ConfigureAwait(false),

if (this._options.EmitAgentResponseEvents)
{
await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false);
await context.YieldOutputAsync(collector.FilterExternallyRaisedApprovals(response), cancellationToken).ConfigureAwait(false);
}

await collector.SubmitAsync(context, cancellationToken).ConfigureAwait(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ internal sealed class AIAgentUnservicedRequestsCollector(AIContentExternalHandle
{
private readonly Dictionary<string, ToolApprovalRequestContent> _userInputRequests = [];
private readonly Dictionary<string, FunctionCallContent> _functionCalls = [];
private readonly Dictionary<string, AgentResponseUpdate> _withheldApprovals = [];
private readonly HashSet<string> _emittedWithheldApprovals = new(StringComparer.Ordinal);

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 +27,18 @@ 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);

// A withheld approval that was neither raised as a request nor already emitted alongside its answer would
// otherwise be lost, so emit it here rather than dropping it.
foreach (KeyValuePair<string, AgentResponseUpdate> withheld in this._withheldApprovals)
{
if (!this._userInputRequests.ContainsKey(withheld.Key)
&& this._emittedWithheldApprovals.Add(withheld.Key))
{
await context.YieldOutputAsync(withheld.Value, cancellationToken).ConfigureAwait(false);
}
}
}

public void ProcessAgentResponseUpdate(AgentResponseUpdate update, Func<FunctionCallContent, bool>? functionCallFilter = null)
Expand Down Expand Up @@ -75,4 +88,169 @@ public void ProcessAIContents(IEnumerable<AIContent> contents, Func<FunctionCall
}
}
}

/// <summary>
/// Returns the approvals withheld from earlier updates that <paramref name="update"/> answers, so they can be
/// emitted ahead of it. Returns an empty list when there are none.
/// </summary>
/// <remarks>
/// A withheld approval whose answer arrives later in the same run is never raised as a request, so it has to be
/// put back on the wire, and it has to go out before the answer or the caller sees the two in reverse order.
/// </remarks>
public IReadOnlyList<AgentResponseUpdate> TakeApprovalsAnsweredBy(AgentResponseUpdate update)
{
if (this._withheldApprovals.Count == 0)
{
return [];
}

List<AgentResponseUpdate>? answered = null;
foreach (AIContent content in update.Contents)
{
if (content is ToolApprovalResponseContent approvalResponse
&& this._withheldApprovals.TryGetValue(approvalResponse.RequestId, out AgentResponseUpdate? withheld)
&& this._emittedWithheldApprovals.Add(approvalResponse.RequestId))
{
(answered ??= []).Add(withheld);
}
}

return answered ?? (IReadOnlyList<AgentResponseUpdate>)[];
}

/// <summary>
/// Returns the update to emit as workflow output, or <see langword="null"/> when the update carried nothing
/// beyond approval requests that this collector raises to an external caller.
/// </summary>
/// <remarks>
/// <para>
/// An approval raised through a request port is surfaced to the caller a second time, cloned with the
/// workflow-facing request ID. That is the only ID the caller can answer with, so emitting the agent-local copy
/// as well leaves the caller with two approval requests for one tool call and no way to tell them apart.
/// </para>
/// <para>
/// Whether the approval really is raised is only settled once the run ends, so each withheld copy is kept and
/// put back on the wire by <see cref="TakeApprovalsAnsweredBy"/> or <see cref="SubmitAsync"/> if no request was
/// raised for it after all. An approval the same update already answers is left in place, since there is no
/// earlier position to restore it to.
/// </para>
/// <para>
/// Function call content is left in place: a function call in an agent stream is normally terminated inline in
/// the same run, and whether it is still unserviced is not known until the stream ends.
/// </para>
/// </remarks>
public AgentResponseUpdate? FilterExternallyRaisedApprovals(AgentResponseUpdate update)
{
if (userInputHandler?.RaisesExternalRequests != true)
{
return update;
}

IList<AIContent> contents = update.Contents;
if (!contents.Any(content => content is ToolApprovalRequestContent))
{
return update;
}

HashSet<string>? answeredHere = null;
foreach (AIContent content in contents)
{
if (content is ToolApprovalResponseContent approvalResponse)
{
(answeredHere ??= new(StringComparer.Ordinal)).Add(approvalResponse.RequestId);
}
}

List<AIContent> retained = [];
foreach (AIContent content in contents)
{
if (content is ToolApprovalRequestContent approvalRequest
&& answeredHere?.Contains(approvalRequest.RequestId) != true)
{
this._withheldApprovals[approvalRequest.RequestId] = CloneWithContents(update, [approvalRequest]);
}
else
{
retained.Add(content);
}
}

return retained.Count > 0 ? CloneWithContents(update, retained) : null;
}

/// <summary>
/// Returns the response to emit as workflow output, with the approval requests that this collector is about to
/// raise to an external caller removed. See <see cref="FilterExternallyRaisedApprovals(AgentResponseUpdate)"/>
/// for why the agent-local copy is not emitted alongside the workflow-facing one.
/// </summary>
/// <remarks>
/// This runs after the whole response has been processed, so the approvals that will be raised are already
/// known. An approval already withheld from a streamed update is removed as well: it reaches the caller either
/// as the raised request or as the re-emission from <see cref="SubmitAsync"/>, never from here.
/// </remarks>
public AgentResponse FilterExternallyRaisedApprovals(AgentResponse response)
{
if (userInputHandler?.RaisesExternalRequests != true
|| (this._userInputRequests.Count == 0 && this._withheldApprovals.Count == 0))
{
return response;
}

if (!response.Messages.Any(message => message.Contents.Any(IsRaisedApproval)))
{
return response;
}

List<ChatMessage> retainedMessages = [];
foreach (ChatMessage message in response.Messages)
{
List<AIContent> retained = message.Contents.Where(content => !IsRaisedApproval(content)).ToList();
if (retained.Count == message.Contents.Count)
{
retainedMessages.Add(message);
}
else if (retained.Count > 0)
{
ChatMessage clone = message.Clone();
clone.Contents = retained;
retainedMessages.Add(clone);
}
}

return CloneWithMessages(response, retainedMessages);

bool IsRaisedApproval(AIContent content)
=> content is ToolApprovalRequestContent approvalRequest
&& (this._userInputRequests.ContainsKey(approvalRequest.RequestId)
|| this._withheldApprovals.ContainsKey(approvalRequest.RequestId));
}

private static AgentResponseUpdate CloneWithContents(AgentResponseUpdate update, IList<AIContent> contents) =>
new()
{
AdditionalProperties = update.AdditionalProperties,
AgentId = update.AgentId,
AuthorName = update.AuthorName,
ContinuationToken = update.ContinuationToken,
Contents = contents,
CreatedAt = update.CreatedAt,
FinishReason = update.FinishReason,
MessageId = update.MessageId,
RawRepresentation = update.RawRepresentation,
ResponseId = update.ResponseId,
Role = update.Role,
};

private static AgentResponse CloneWithMessages(AgentResponse response, IList<ChatMessage> messages) =>
new(messages)
{
AdditionalProperties = response.AdditionalProperties,
AgentId = response.AgentId,
ContinuationToken = response.ContinuationToken,
CreatedAt = response.CreatedAt,
FinishReason = response.FinishReason,
RawRepresentation = response.RawRepresentation,
ResponseId = response.ResponseId,
Usage = response.Usage,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ void ConfigureRoutes(RouteBuilder routeBuilder, out PortBinding? portBinding)

public bool HasPendingRequests => !this._pendingRequests.IsEmpty;

/// <summary>
/// Gets a value indicating whether requests are raised to an external caller through a request port,
/// rather than being handled by another executor inside the workflow.
/// </summary>
/// <remarks>
/// A request raised through the port is surfaced to the caller again, carrying the workflow-facing
/// request ID that the caller has to answer with. The executor that owns this handler uses this to
/// avoid also emitting the original request content as its own output.
/// </remarks>
public bool RaisesExternalRequests => !this.IsIntercepted;

public Task ProcessRequestContentsAsync(Dictionary<string, TRequestContent> requests, IWorkflowContext context, CancellationToken cancellationToken = default)
{
IEnumerable<Task> requestTasks = from string requestId in requests.Keys
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -485,18 +485,34 @@ await AddUpdateAsync(

if (this._options.EmitAgentResponseEvents)
{
await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false);
await context.YieldOutputAsync(collector.FilterExternallyRaisedApprovals(response), cancellationToken).ConfigureAwait(false);
}

await collector.SubmitAsync(context, cancellationToken).ConfigureAwait(false);

return new(response, LookupHandoffTarget(requestedHandoff));

ValueTask AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken)
async ValueTask AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken)
{
updates.Add(update);

return emitUpdateEvents ? context.YieldOutputAsync(update, cancellationToken) : default;
if (!emitUpdateEvents)
{
return;
}

// Approval requests this executor raises through its request port are surfaced to the caller
// with the workflow-facing request ID, so the agent-local copy is not emitted as output too.
// An approval the agent answers itself is never raised, so it goes out ahead of its answer.
foreach (AgentResponseUpdate answered in collector.TakeApprovalsAnsweredBy(update))
{
await context.YieldOutputAsync(answered, cancellationToken).ConfigureAwait(false);
}

if (collector.FilterExternallyRaisedApprovals(update) is AgentResponseUpdate emittedUpdate)
{
await context.YieldOutputAsync(emittedUpdate, cancellationToken).ConfigureAwait(false);
}
}

string? LookupHandoffTarget(string? requestedHandoff)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,51 @@ public async Task Handoffs_TwoTransfers_SecondAgentUserApproval_ResponseServedBy
static bool SomeOtherFunction() => true;
}

[Fact]
public async Task Handoffs_UserApproval_AsAgentStreamingSurfacesApprovalOnceAsync()
{
// Arrange
const string ApprovalFunctionCallId = "approval-call-id";
AIFunction approvalRequiredFunction = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(ProtectedFunction));

var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
{
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
Assert.NotNull(transferFuncName);

return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)]));
}), name: "initialAgent");

var secondAgent = new ChatClientAgent(
new MockChatClient((messages, options) =>
new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent(ApprovalFunctionCallId, approvalRequiredFunction.Name)]))),
name: "secondAgent",
description: "The second agent",
tools: [approvalRequiredFunction]);

Workflow workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent)
.WithHandoff(initialAgent, secondAgent)
.Build();

// Act
List<AgentResponseUpdate> updates = await workflow.AsAIAgent(name: "ApprovalHandoffWorkflow")
.RunStreamingAsync("abc")
.ToListAsync();

// Assert
AgentResponseUpdate approvalUpdate = updates
.Should().ContainSingle(update => update.Contents.Any(content => content is ToolApprovalRequestContent))
.Which;

approvalUpdate.RawRepresentation.Should().BeOfType<RequestInfoEvent>(
"the workflow-facing request carries the only request ID the caller can answer with");
approvalUpdate.Contents.OfType<ToolApprovalRequestContent>()
.Should().ContainSingle()
.Which.ToolCall.CallId.Should().Be(ApprovalFunctionCallId);

static bool ProtectedFunction() => true;
}

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