.NET: Declarative workflows - Gracefully handle agent scenarios when no response is returned (#5376)

* Gracefully handle agent scenarios when no response is returned

* Make relevant object disposable and improve exception handling.
This commit is contained in:
Peter Ibekwe
2026-04-21 08:04:49 -07:00
committed by GitHub
Unverified
parent d5777bc546
commit adcd2d33f5
5 changed files with 68 additions and 17 deletions
@@ -4,7 +4,6 @@ using System;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Text.Json.Nodes;
@@ -70,7 +69,14 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj
include: null,
cancellationToken).ConfigureAwait(false);
return newItems.AsChatMessages().Single();
ChatMessage[] createdMessages = [.. newItems.AsChatMessages()];
if (createdMessages.Length != 1)
{
throw new InvalidOperationException(
$"Expected exactly one chat message from created conversation item in conversation '{conversationId}', but got {createdMessages.Length}.");
}
return createdMessages[0];
IEnumerable<ResponseItem> GetResponseItems()
{
@@ -208,7 +214,14 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj
{
AgentResponseItem responseItem = await this.GetConversationClient().GetProjectConversationItemAsync(conversationId, messageId, include: null, cancellationToken).ConfigureAwait(false);
ResponseItem[] items = [responseItem.AsResponseResultItem()];
return items.AsChatMessages().Single();
ChatMessage[] messages = [.. items.AsChatMessages()];
if (messages.Length != 1)
{
throw new InvalidOperationException(
$"Expected exactly one chat message for message '{messageId}' in conversation '{conversationId}', but got {messages.Length}.");
}
return messages[0];
}
/// <inheritdoc/>
@@ -49,7 +49,11 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, ResponseA
public async ValueTask ResumeAsync(IWorkflowContext context, ExternalInputResponse response, CancellationToken cancellationToken)
{
await context.SetLastMessageAsync(response.Messages.Last()).ConfigureAwait(false);
ChatMessage? lastMessage = response.Messages.LastOrDefault();
if (lastMessage is not null)
{
await context.SetLastMessageAsync(lastMessage).ConfigureAwait(false);
}
await this.InvokeAgentAsync(context, response.Messages, cancellationToken).ConfigureAwait(false);
}
@@ -85,15 +89,19 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, ResponseA
await this.AssignAsync(this.AgentOutput?.Messages?.Path, agentResponse.Messages.ToTable(), context).ConfigureAwait(false);
// Attempt to parse the last message as JSON and assign to the response object variable.
try
string? lastMessageText = agentResponse.Messages.LastOrDefault()?.Text;
if (!string.IsNullOrEmpty(lastMessageText))
{
JsonDocument jsonDocument = JsonDocument.Parse(agentResponse.Messages.Last().Text);
Dictionary<string, object?> objectProperties = jsonDocument.ParseRecord(VariableType.RecordType);
await this.AssignAsync(this.AgentOutput?.ResponseObject?.Path, objectProperties.ToFormula(), context).ConfigureAwait(false);
}
catch
{
// Not valid json, skip assignment.
try
{
using JsonDocument jsonDocument = JsonDocument.Parse(lastMessageText);
Dictionary<string, object?> objectProperties = jsonDocument.ParseRecord(VariableType.RecordType);
await this.AssignAsync(this.AgentOutput?.ResponseObject?.Path, objectProperties.ToFormula(), context).ConfigureAwait(false);
}
catch (JsonException)
{
// Not valid json, skip assignment.
}
}
if (this.Model.Input?.ExternalLoop?.When is not null)
@@ -122,10 +122,13 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age
string? workflowConversationId = context.GetWorkflowConversation();
if (workflowConversationId is not null)
{
// Input message always defined if values has been extracted.
ChatMessage input = response.Messages.Last();
await agentProvider.CreateMessageAsync(workflowConversationId, input, cancellationToken).ConfigureAwait(false);
await context.SetLastMessageAsync(input).ConfigureAwait(false);
// Input message expected to be defined when values have been extracted, but guard defensively.
ChatMessage? input = response.Messages.LastOrDefault();
if (input is not null)
{
await agentProvider.CreateMessageAsync(workflowConversationId, input, cancellationToken).ConfigureAwait(false);
await context.SetLastMessageAsync(input).ConfigureAwait(false);
}
}
}
@@ -45,7 +45,11 @@ internal sealed class RequestExternalInputExecutor(RequestExternalInput model, R
await agentProvider.CreateMessageAsync(workflowConversationId, inputMessage, cancellationToken).ConfigureAwait(false);
}
}
await context.SetLastMessageAsync(response.Messages.Last()).ConfigureAwait(false);
ChatMessage? lastMessage = response.Messages.LastOrDefault();
if (lastMessage is not null)
{
await context.SetLastMessageAsync(lastMessage).ConfigureAwait(false);
}
await this.AssignAsync(this.Model.Variable?.Path, response.Messages.ToFormula(), context).ConfigureAwait(false);
await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false);
@@ -85,6 +85,29 @@ public sealed class RequestExternalInputExecutorTest(ITestOutputHelper output) :
expectMessagesCreated: true);
}
[Fact]
public async Task CaptureResponseWithEmptyMessagesAsync()
{
await this.CaptureResponseTestAsync(
displayName: nameof(CaptureResponseWithEmptyMessagesAsync),
variableName: "TestVariable",
messageCount: 0);
}
[Fact]
public async Task CaptureResponseWithEmptyMessagesAndWorkflowConversationAsync()
{
// Arrange
this.State.Set(SystemScope.Names.ConversationId, FormulaValue.New("WorkflowConversationId"), VariableScopeNames.System);
// Act & Assert
await this.CaptureResponseTestAsync(
displayName: nameof(CaptureResponseWithEmptyMessagesAndWorkflowConversationAsync),
variableName: "TestVariable",
messageCount: 0,
expectMessagesCreated: false);
}
private async Task ExecuteTestAsync(
string displayName,
string variableName)