Compare commits

...

2 Commits

Author SHA1 Message Date
Peter Ibekwe 22f2d29054 Fix PR comments. 2026-07-08 16:07:05 -07:00
Peter Ibekwe e87e42494c Fix declarative InvokeAzureAgent failing on non-object JSON agent output 2026-07-08 15:27:47 -07:00
7 changed files with 497 additions and 40 deletions
@@ -42,6 +42,28 @@ internal static class JsonDocumentExtensions
};
}
/// <summary>
/// Maps a successfully-parsed JSON document's root element to a CLR value by its <see cref="JsonValueKind"/>.
/// Mirrors the value-kind handling shared by the agent/tool/HTTP executors: objects become records,
/// arrays become lists, and scalars become their primitive value.
/// </summary>
/// <param name="jsonDocument">The parsed JSON document.</param>
/// <param name="rawJson">The original JSON text, returned as a fallback when the root kind is undefined.</param>
/// <returns>The parsed CLR value.</returns>
public static object? ParseJsonValue(this JsonDocument jsonDocument, string rawJson) =>
jsonDocument.RootElement.ValueKind switch
{
JsonValueKind.Object => jsonDocument.ParseRecord(VariableType.RecordType),
JsonValueKind.Array when jsonDocument.RootElement.GetArrayLength() == 0 => new List<object?>(),
JsonValueKind.Array => jsonDocument.ParseList(jsonDocument.RootElement.GetListTypeFromJson()),
JsonValueKind.String => jsonDocument.RootElement.GetString(),
JsonValueKind.Number => jsonDocument.RootElement.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
_ => rawJson,
};
/// <summary>
/// Creates a VariableType.List with schema inferred from the first object element in the array.
/// </summary>
@@ -7,7 +7,6 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
@@ -177,19 +176,7 @@ internal sealed class HttpRequestExecutor(
{
using JsonDocument jsonDocument = JsonDocument.Parse(responseBody);
object? parsedValue = jsonDocument.RootElement.ValueKind switch
{
JsonValueKind.Object => jsonDocument.ParseRecord(VariableType.RecordType),
JsonValueKind.Array => jsonDocument.ParseList(jsonDocument.RootElement.GetListTypeFromJson()),
JsonValueKind.String => jsonDocument.RootElement.GetString(),
JsonValueKind.Number => jsonDocument.RootElement.TryGetInt64(out long l)
? l
: jsonDocument.RootElement.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
_ => responseBody,
};
object? parsedValue = jsonDocument.ParseJsonValue(responseBody);
return parsedValue.ToFormula();
}
@@ -13,6 +13,7 @@ using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Agents.ObjectModel.Abstractions;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
@@ -89,18 +90,29 @@ 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.
PropertyPath? responseObjectPath = this.AgentOutput?.ResponseObject?.Path;
string? lastMessageText = agentResponse.Messages.LastOrDefault()?.Text;
if (!string.IsNullOrEmpty(lastMessageText))
if (responseObjectPath is not null && !string.IsNullOrEmpty(lastMessageText))
{
FormulaValue? responseObjectValue = null;
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);
responseObjectValue = jsonDocument.ParseJsonValue(lastMessageText).ToFormula();
}
catch (JsonException)
{
// Not valid json, skip assignment.
// Not valid JSON — skip assignment.
}
catch (DeclarativeWorkflowException)
{
// Valid JSON, but not convertible to a workflow value (e.g. a mixed-type or nested array).
// Output parsing is best-effort — skip assignment rather than fail the action.
}
if (responseObjectValue is not null)
{
await this.AssignAsync(responseObjectPath, responseObjectValue, context).ConfigureAwait(false);
}
}
@@ -357,17 +357,7 @@ internal sealed class InvokeFunctionToolExecutor(
{
using JsonDocument jsonDocument = JsonDocument.Parse(jsonString);
// Handle different JSON value kinds
object? parsedValue = jsonDocument.RootElement.ValueKind switch
{
JsonValueKind.Object => jsonDocument.ParseRecord(VariableType.RecordType),
JsonValueKind.Array => jsonDocument.ParseList(jsonDocument.RootElement.GetListTypeFromJson()),
JsonValueKind.String => jsonDocument.RootElement.GetString(),
JsonValueKind.Number => jsonDocument.RootElement.TryGetInt64(out long l) ? l : jsonDocument.RootElement.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
_ => jsonString,
};
object? parsedValue = jsonDocument.ParseJsonValue(jsonString);
await this.AssignAsync(this.Model.Output.Result?.Path, parsedValue.ToFormula(), context).ConfigureAwait(false);
return;
}
@@ -302,17 +302,7 @@ internal sealed class InvokeMcpToolExecutor(
using JsonDocument jsonDocument = JsonDocument.Parse(jsonString);
// Handle different JSON value kinds
object? parsedValue = jsonDocument.RootElement.ValueKind switch
{
JsonValueKind.Object => jsonDocument.ParseRecord(VariableType.RecordType),
JsonValueKind.Array => jsonDocument.ParseList(jsonDocument.RootElement.GetListTypeFromJson()),
JsonValueKind.String => jsonDocument.RootElement.GetString(),
JsonValueKind.Number => jsonDocument.RootElement.TryGetInt64(out long l) ? l : jsonDocument.RootElement.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
_ => jsonString,
};
object? parsedValue = jsonDocument.ParseJsonValue(jsonString);
parsedResults.Add(parsedValue);
continue;
@@ -663,4 +663,102 @@ public sealed class JsonDocumentExtensionsTests
Assert.Equal(VariableType.ListType, result.Schema["list"].Type);
Assert.Equal(typeof(string), result.Schema["empty"].Type);
}
[Fact]
public void ParseJsonValue_Object_ReturnsRecord()
{
// Arrange
JsonDocument document = JsonDocument.Parse("""{ "a": "alpha", "b": "beta" }""");
// Act
object? result = document.ParseJsonValue("""{ "a": "alpha", "b": "beta" }""");
// Assert
Dictionary<string, object?> record = Assert.IsType<Dictionary<string, object?>>(result);
Assert.Equal("alpha", record["a"]);
Assert.Equal("beta", record["b"]);
}
[Fact]
public void ParseJsonValue_Array_ReturnsList()
{
// Arrange
const string Json = """["alpha","beta"]""";
JsonDocument document = JsonDocument.Parse(Json);
// Act
object? result = document.ParseJsonValue(Json);
// Assert
List<object?> list = Assert.IsType<List<object?>>(result);
Assert.Equal(new object?[] { "alpha", "beta" }, list);
}
[Fact]
public void ParseJsonValue_EmptyArray_ReturnsEmptyList()
{
// Arrange
JsonDocument document = JsonDocument.Parse("[]");
// Act
object? result = document.ParseJsonValue("[]");
// Assert
List<object?> list = Assert.IsType<List<object?>>(result);
Assert.Empty(list);
}
[Fact]
public void ParseJsonValue_String_ReturnsString()
{
// Arrange
JsonDocument document = JsonDocument.Parse("\"hello\"");
// Act
object? result = document.ParseJsonValue("\"hello\"");
// Assert
Assert.Equal("hello", result);
}
[Fact]
public void ParseJsonValue_Number_ReturnsNumericValue()
{
// Arrange
JsonDocument document = JsonDocument.Parse("42");
// Act
object? result = document.ParseJsonValue("42");
// Assert
Assert.Equal(42d, Assert.IsType<double>(result));
}
[Theory]
[InlineData("true", true)]
[InlineData("false", false)]
public void ParseJsonValue_Boolean_ReturnsBoolean(string json, bool expected)
{
// Arrange
JsonDocument document = JsonDocument.Parse(json);
// Act
object? result = document.ParseJsonValue(json);
// Assert
Assert.Equal(expected, result);
}
[Fact]
public void ParseJsonValue_Null_ReturnsNull()
{
// Arrange
JsonDocument document = JsonDocument.Parse("null");
// Act
object? result = document.ParseJsonValue("null");
// Assert
Assert.Null(result);
}
}
@@ -0,0 +1,358 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="InvokeAzureAgentExecutor"/>.
/// </summary>
public sealed class InvokeAzureAgentExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public void InvokeAzureAgentThrowsWhenModelInvalid() =>
// Arrange, Act & Assert
Assert.Throws<DeclarativeModelException>(() => new InvokeAzureAgentExecutor(new InvokeAzureAgent(), new CapturingAgentProvider("text"), this.State));
#region Input argument binding
[Fact]
public async Task MultipleNamedArgumentsAreAllBoundAsync()
{
// Arrange
this.State.InitializeSystem();
CapturingAgentProvider provider = new("acknowledged");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(MultipleNamedArgumentsAreAllBoundAsync),
agentName: "BrainCombine",
arguments:
[
("a", ValueExpression.Literal(new StringDataValue("alpha"))),
("b", ValueExpression.Literal(new StringDataValue("beta"))),
]);
// Act
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
// Assert
Assert.NotNull(provider.CapturedArguments);
Assert.Equal("alpha", provider.CapturedArguments!["a"]);
Assert.Equal("beta", provider.CapturedArguments!["b"]);
}
[Fact]
public async Task RecordValuedArgumentIsBoundAsRecordAsync()
{
// Arrange
this.State.InitializeSystem();
this.State.Set(
"R",
FormulaValue.NewRecordFromFields(
new NamedValue("a", FormulaValue.New("alpha")),
new NamedValue("b", FormulaValue.New("beta"))));
CapturingAgentProvider provider = new("acknowledged");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(RecordValuedArgumentIsBoundAsRecordAsync),
agentName: "BrainTest",
arguments:
[
("input", ValueExpression.Variable(PropertyPath.TopicVariable("R"))),
]);
// Act
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
// Assert
Assert.NotNull(provider.CapturedArguments);
IDictionary<string, object?> record = Assert.IsAssignableFrom<IDictionary<string, object?>>(provider.CapturedArguments!["input"]);
Assert.Equal("alpha", record["a"]);
Assert.Equal("beta", record["b"]);
}
[Fact]
public async Task InlineRecordExpressionArgumentIsBoundAsRecordAsync()
{
// Arrange
this.State.InitializeSystem();
CapturingAgentProvider provider = new("acknowledged");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(InlineRecordExpressionArgumentIsBoundAsRecordAsync),
agentName: "BrainInlineRecord",
arguments:
[
("input", ValueExpression.Expression("""{ a: "alpha", b: "beta" }""")),
]);
// Act (reporter repro (b): a single argument whose value is an inline record literal)
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
// Assert
Assert.NotNull(provider.CapturedArguments);
IDictionary<string, object?> record = Assert.IsAssignableFrom<IDictionary<string, object?>>(provider.CapturedArguments!["input"]);
Assert.Equal("alpha", record["a"]);
Assert.Equal("beta", record["b"]);
}
#endregion
#region Response object parsing
[Fact]
public async Task JsonObjectOutputAssignsRecordAsync()
{
// Arrange
this.State.InitializeSystem();
CapturingAgentProvider provider = new("""{ "a": "alpha", "b": "beta" }""");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(JsonObjectOutputAssignsRecordAsync),
agentName: "BrainObject",
responseObjectVariable: "Result");
// Act
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
// Assert
RecordValue record = Assert.IsAssignableFrom<RecordValue>(this.State.Get("Result"));
Assert.Equal("alpha", ((StringValue)record.GetField("a")).Value);
}
[Fact]
public async Task JsonArrayOutputAssignsListAsync()
{
// Arrange
this.State.InitializeSystem();
CapturingAgentProvider provider = new("""["alpha","beta"]""");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(JsonArrayOutputAssignsListAsync),
agentName: "BrainArray",
responseObjectVariable: "Result");
// Act
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
// Assert
TableValue table = Assert.IsAssignableFrom<TableValue>(this.State.Get("Result"));
Assert.Equal(2, table.Rows.Count());
}
[Fact]
public async Task EmptyJsonArrayOutputAssignsEmptyListAsync()
{
// Arrange
this.State.InitializeSystem();
CapturingAgentProvider provider = new("[]");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(EmptyJsonArrayOutputAssignsEmptyListAsync),
agentName: "BrainEmptyArray",
responseObjectVariable: "Result");
// Act
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
// Assert
TableValue table = Assert.IsAssignableFrom<TableValue>(this.State.Get("Result"));
Assert.Empty(table.Rows);
}
[Fact]
public async Task JsonScalarOutputAssignsScalarAsync()
{
// Arrange
this.State.InitializeSystem();
CapturingAgentProvider provider = new("42");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(JsonScalarOutputAssignsScalarAsync),
agentName: "BrainScalar",
responseObjectVariable: "Result");
// Act
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
// Assert
NumberValue number = Assert.IsAssignableFrom<NumberValue>(this.State.Get("Result"));
Assert.Equal(42d, number.Value);
}
[Fact]
public async Task MixedJsonArrayOutputSkipsAssignmentAsync()
{
// Arrange
this.State.InitializeSystem();
CapturingAgentProvider provider = new("""["alpha",1]""");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(MixedJsonArrayOutputSkipsAssignmentAsync),
agentName: "BrainMixedArray",
responseObjectVariable: "Result");
// Act (must not throw despite non-convertible JSON)
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
// Assert
this.VerifyUndefined("Result");
}
[Fact]
public async Task NestedJsonArrayOutputSkipsAssignmentAsync()
{
// Arrange
this.State.InitializeSystem();
CapturingAgentProvider provider = new("[[1,2],[3,4]]");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(NestedJsonArrayOutputSkipsAssignmentAsync),
agentName: "BrainNestedArray",
responseObjectVariable: "Result");
// Act (a nested array parses but is not convertible to a workflow value — must not throw)
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
// Assert
this.VerifyUndefined("Result");
}
[Fact]
public async Task PlainTextOutputSkipsAssignmentAsync()
{
// Arrange
this.State.InitializeSystem();
CapturingAgentProvider provider = new("hello world");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(PlainTextOutputSkipsAssignmentAsync),
agentName: "BrainText",
responseObjectVariable: "Result");
// Act
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
// Assert
this.VerifyUndefined("Result");
}
[Fact]
public async Task NonObjectJsonOutputWithoutResponseObjectDoesNotThrowAsync()
{
// Arrange
this.State.InitializeSystem();
CapturingAgentProvider provider = new("""["alpha","beta"]""");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(NonObjectJsonOutputWithoutResponseObjectDoesNotThrowAsync),
agentName: "BrainNoOutput");
// Act & Assert (reporter repro shape — no output block; must not throw)
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
}
#endregion
#region Helpers
private InvokeAzureAgent CreateModel(
string displayName,
string agentName,
IReadOnlyList<(string Key, ValueExpression Value)>? arguments = null,
string? responseObjectVariable = null)
{
InvokeAzureAgent.Builder builder =
new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
Agent =
new AzureAgentUsage.Builder
{
Name = new StringExpression.Builder(StringExpression.Literal(agentName)),
},
};
if (arguments is not null)
{
AzureAgentInput.Builder inputBuilder = new();
foreach ((string key, ValueExpression value) in arguments)
{
inputBuilder.Arguments.Add(key, value);
}
builder.Input = inputBuilder;
}
if (responseObjectVariable is not null)
{
builder.Output =
new AzureAgentOutput.Builder
{
AutoSend = new BoolExpression.Builder(BoolExpression.Literal(false)),
ResponseObject = new InitializablePropertyPath(PropertyPath.TopicVariable(responseObjectVariable), isInitializer: false),
};
}
return AssignParent<InvokeAzureAgent>(builder);
}
/// <summary>
/// Minimal <see cref="ResponseAgentProvider"/> that returns a single configured text response and
/// captures the input arguments supplied to <see cref="InvokeAgentAsync"/>.
/// </summary>
private sealed class CapturingAgentProvider(string responseText) : ResponseAgentProvider
{
public IDictionary<string, object?>? CapturedArguments { get; private set; }
public override IAsyncEnumerable<AgentResponseUpdate> InvokeAgentAsync(
string agentId,
string? agentVersion,
string? conversationId,
IEnumerable<ChatMessage>? messages,
IDictionary<string, object?>? inputArguments,
CancellationToken cancellationToken = default)
{
this.CapturedArguments = inputArguments;
return YieldAsync(responseText);
}
public override Task<string> CreateConversationAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(Guid.NewGuid().ToString("N"));
public override Task<ChatMessage> CreateMessageAsync(string conversationId, ChatMessage conversationMessage, CancellationToken cancellationToken = default) =>
Task.FromResult(conversationMessage);
public override Task<ChatMessage> GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public override async IAsyncEnumerable<ChatMessage> GetMessagesAsync(
string conversationId,
int? limit = null,
string? after = null,
string? before = null,
bool newestFirst = false,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.CompletedTask.ConfigureAwait(false);
yield break;
}
private static async IAsyncEnumerable<AgentResponseUpdate> YieldAsync(string text)
{
yield return new AgentResponseUpdate(ChatRole.Assistant, text);
await Task.CompletedTask.ConfigureAwait(false);
}
}
#endregion
}