Skip to content
Merged
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 @@ -4,6 +4,7 @@
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Text.Json.Nodes;
Expand Down Expand Up @@ -132,13 +133,80 @@ messages is not null ?
agent.RunStreamingAsync([.. messages], null, runOptions, cancellationToken) :
agent.RunStreamingAsync([], null, runOptions, cancellationToken);

await foreach (AgentResponseUpdate update in agentResponse.ConfigureAwait(false))
await foreach (AgentResponseUpdate update in WithFailureDetectionAsync(agentResponse, agentVersionResult.Name, cancellationToken).ConfigureAwait(false))
{
update.AuthorName = agentVersionResult.Name;
yield return update;
}
}

/// <summary>
/// Surfaces a failed Responses API run as <see cref="ErrorContent"/>.
/// </summary>
/// <remarks>
/// <para>
/// <c>Microsoft.Extensions.AI.OpenAI</c> maps the <c>response.failed</c> event onto a
/// contentless update, leaving a failed run indistinguishable from an empty successful one.
/// </para>
/// <para>
/// The failed update is replaced rather than supplemented: it carries the provider's error text
/// in its raw representation, and updates reach clients verbatim regardless of the host's
/// exception-detail policy.
/// </para>
/// </remarks>
internal static async IAsyncEnumerable<AgentResponseUpdate> WithFailureDetectionAsync(
IAsyncEnumerable<AgentResponseUpdate> updates,
string? authorName,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await foreach (AgentResponseUpdate update in updates.WithCancellation(cancellationToken).ConfigureAwait(false))
{
update.AuthorName = authorName;

yield return TryCreateFailureUpdate(update, authorName, out AgentResponseUpdate? failureUpdate)
? failureUpdate
: update;
}
}

/// <summary>
/// Builds an <see cref="ErrorContent"/> update when <paramref name="update"/> represents a failed run.
/// </summary>
private static bool TryCreateFailureUpdate(
AgentResponseUpdate update,
string? authorName,
[NotNullWhen(true)] out AgentResponseUpdate? failureUpdate)
{
failureUpdate = null;

if (update.RawRepresentation is not ChatResponseUpdate chatUpdate ||
chatUpdate.RawRepresentation is not StreamingResponseFailedUpdate failedUpdate)
{
return false;
}

ResponseError? error = failedUpdate.Response?.Error;

// A failure with no detail must still explain itself to the client.
ErrorContent errorContent =
new(string.IsNullOrWhiteSpace(error?.Message) ? DefaultFailureMessage : error!.Message)
{
ErrorCode = error?.Code.ToString() is { Length: > 0 } code ? code : DefaultFailureCode,
};

failureUpdate =
new(ChatRole.Assistant, [errorContent])
{
AuthorName = authorName,
ResponseId = update.ResponseId ?? failedUpdate.Response?.Id,
CreatedAt = update.CreatedAt,
};

return true;
}

private const string DefaultFailureMessage = "The agent run failed.";
private const string DefaultFailureCode = "failed";

private async Task<ProjectsAgentVersion> QueryAgentAsync(string agentName, string? agentVersion, CancellationToken cancellationToken = default)
{
string agentKey = $"{agentName}:{agentVersion}";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,19 @@ public static async ValueTask<AgentResponse> InvokeAgentAsync(

updates.Add(update);

if (autoSend)
// Error updates are withheld: they reach the client verbatim, bypassing the host's
// exception-detail policy. The detail still arrives via the thrown exception.
if (autoSend && !HasError(update))
{
await context.AddEventAsync(new AgentResponseUpdateEvent(executorId, update), cancellationToken).ConfigureAwait(false);
}
}

AgentResponse response = updates.ToAgentResponse();

// Fail before the response is announced as completed or copied to the conversation.
ThrowIfFailed(response, agentName);

if (autoSend)
{
await context.AddEventAsync(new AgentResponseEvent(executorId, response), cancellationToken).ConfigureAwait(false);
Expand All @@ -111,4 +116,41 @@ async ValueTask AssignConversationIdAsync(string? assignValue)
}
}
}

/// <summary>
/// Indicates whether an update carries an agent error rather than usable content.
/// </summary>
private static bool HasError(AgentResponseUpdate update) =>
update.Contents.Any(content => content is ErrorContent);

/// <summary>
/// Fails the action when the agent reported an error rather than a usable response.
/// </summary>
/// <remarks>
/// Any top-level <see cref="ErrorContent"/> is a failure, covering both a failed run and a
/// refusal, and excluding <c>incomplete</c>, which carries partial content instead. The detail
/// is folded into the exception so one error path stays under the host's exception-detail policy.
/// </remarks>
private static void ThrowIfFailed(AgentResponse response, string agentName)
{
// The last error wins: a run that fails without detail yields a generic placeholder first,
// and the specific cause follows as its own error.
ErrorContent? error =
response.Messages
.SelectMany(message => message.Contents)
.OfType<ErrorContent>()
.LastOrDefault();

if (error is null)
{
return;
}

string errorCode = string.IsNullOrWhiteSpace(error.ErrorCode) ? "unknown" : error.ErrorCode!;
string errorMessage = string.IsNullOrWhiteSpace(error.Message) ? "No error message was provided." : error.Message!;

// No inner exception: DeclarativeActionException is unwrapped to its inner exception when
// reported, which would discard this message.
throw new DeclarativeActionException($"Agent '{agentName}' failed [{errorCode}]: {errorMessage}");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using OpenAI.Responses;

namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests;

/// <summary>
/// Helpers for building and driving <see cref="AgentResponseUpdate"/> sequences in tests.
/// </summary>
internal static class AgentUpdateTestHelpers
{
/// <summary>
/// The response identifier carried by updates built with <see cref="CreateFailedUpdate"/>.
/// </summary>
private const string FailedResponseId = "resp_test";

/// <summary>
/// Presents updates as the asynchronous sequence a <see cref="ResponseAgentProvider"/> returns.
/// </summary>
public static async IAsyncEnumerable<AgentResponseUpdate> ToAsyncEnumerableAsync(IEnumerable<AgentResponseUpdate> updates)
{
foreach (AgentResponseUpdate update in updates)
{
yield return update;
}

await Task.CompletedTask;
}

/// <summary>
/// Runs updates through the provider's failure-detection transform, reproducing what
/// <see cref="AzureAgentProvider"/> emits when streaming an agent run.
/// </summary>
public static async Task<List<AgentResponseUpdate>> ApplyFailureDetectionAsync(
string agentName,
params AgentResponseUpdate[] updates)
{
List<AgentResponseUpdate> results = [];

await foreach (AgentResponseUpdate update in
AzureAgentProvider.WithFailureDetectionAsync(ToAsyncEnumerableAsync(updates), agentName))
{
results.Add(update);
}

return results;
}

/// <summary>
/// Builds the update shape <c>Microsoft.Extensions.AI.OpenAI</c> produces for a Responses
/// <c>response.failed</c> event. Pass a null <paramref name="errorCode"/> to model a failure
/// that carries no error detail.
/// </summary>
public static AgentResponseUpdate CreateFailedUpdate(string? errorCode, string? errorMessage)
{
string errorJson =
errorCode is null
? "null"
: $$"""{"code":"{{errorCode}}","message":"{{errorMessage}}"}""";

string payload =
$$"""
{
"type": "response.failed",
"sequence_number": 1,
"response": {
"id": "{{FailedResponseId}}",
"object": "response",
"created_at": 1700000000,
"status": "failed",
"model": "gpt-test",
"output": [],
"error": {{errorJson}}
}
}
""";

StreamingResponseUpdate streamingUpdate =
ModelReaderWriter.Read<StreamingResponseUpdate>(BinaryData.FromString(payload))!;

// Guards the assumption the production unwrap depends on.
Assert.IsType<StreamingResponseFailedUpdate>(streamingUpdate);

ChatResponseUpdate chatUpdate =
new(ChatRole.Assistant, (IList<AIContent>?)null)
{
ResponseId = FailedResponseId,
RawRepresentation = streamingUpdate,
};

return new AgentResponseUpdate(chatUpdate);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using OpenAI.Responses;

namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests;

/// <summary>
/// Verifies that <see cref="AzureAgentProvider"/> restores the failure signal that
/// <c>Microsoft.Extensions.AI.OpenAI</c> drops when it maps the Responses <c>response.failed</c>
/// event onto a contentless update.
/// </summary>
public sealed class AzureAgentProviderFailureTest(ITestOutputHelper output) : WorkflowTest(output)
{
private const string AgentName = "TestAgent";

[Fact]
public async Task FailedResponseYieldsErrorContentAsync()
{
// Arrange
AgentResponseUpdate failed = FailedUpdate("server_error", "Something went wrong.");

// Act
AgentResponseUpdate[] results = await CollectAsync(failed);

// Assert
// The failed update is replaced, not supplemented: its raw representation carries provider
// error text that must not reach clients.
AgentResponseUpdate result = Assert.Single(results);
ErrorContent error = Assert.Single(result.Contents.OfType<ErrorContent>());
Assert.Equal("Something went wrong.", error.Message);
Assert.Equal("server_error", error.ErrorCode);
Assert.Null((result.RawRepresentation as ChatResponseUpdate)?.RawRepresentation as StreamingResponseFailedUpdate);

// Correlation is carried onto the replacement.
Assert.Equal(AgentName, result.AuthorName);
Assert.Equal(failed.ResponseId, result.ResponseId);
}

[Fact]
public async Task FailedResponseWithoutErrorDetailUsesFallbackAsync()
{
// Arrange - a failed response that carries no error object.
AgentResponseUpdate failed = FailedUpdate(errorCode: null, errorMessage: null);

// Act
AgentResponseUpdate[] results = await CollectAsync(failed);

// Assert - a failure with no detail must still explain itself.
ErrorContent error = Assert.Single(results.SelectMany(update => update.Contents).OfType<ErrorContent>());
Assert.False(string.IsNullOrWhiteSpace(error.Message));
Assert.False(string.IsNullOrWhiteSpace(error.ErrorCode));
}

[Fact]
public async Task SuccessfulUpdatesPassThroughUntouchedAsync()
{
// Arrange
AgentResponseUpdate text = new(ChatRole.Assistant, [new TextContent("All good.")]);
AgentResponseUpdate contentless = new(ChatRole.Assistant, []);

// Act
AgentResponseUpdate[] results = await CollectAsync(text, contentless);

// Assert - no synthesized failure is introduced.
Assert.Equal(2, results.Length);
Assert.Empty(results.SelectMany(update => update.Contents).OfType<ErrorContent>());
Assert.All(results, update => Assert.Equal(AgentName, update.AuthorName));
}

private static async Task<AgentResponseUpdate[]> CollectAsync(params AgentResponseUpdate[] updates) =>
[.. await AgentUpdateTestHelpers.ApplyFailureDetectionAsync(AgentName, updates)];

/// <summary>
/// Builds the update shape produced for a <c>response.failed</c> event.
/// </summary>
private static AgentResponseUpdate FailedUpdate(string? errorCode, string? errorMessage) =>
AgentUpdateTestHelpers.CreateFailedUpdate(errorCode, errorMessage);
}
Loading
Loading