Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8d64ffe2b |
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
@@ -248,7 +247,7 @@ internal sealed class WorkflowSession : AgentSession
|
||||
hasMatchedResponseForStartExecutor |= string.Equals(responseExecutorId, this._workflow.StartExecutorId, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
object normalizedResponseContent = NormalizeResponseContentForDelivery(content, pendingRequest);
|
||||
object normalizedResponseContent = this.NormalizeResponseContentForDelivery(content, pendingRequest);
|
||||
externalResponses.Add((pendingRequest.CreateResponse(normalizedResponseContent), pendingRequest.RequestId));
|
||||
(matchedContentIds ??= new(StringComparer.Ordinal)).Add(contentId);
|
||||
}
|
||||
@@ -288,24 +287,19 @@ internal sealed class WorkflowSession : AgentSession
|
||||
hasMatchedResponseForStartExecutor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the concrete request payload type from <see cref="RequestPortInfo.RequestType"/>
|
||||
/// and returns it as an <see cref="IExternalRequestEnvelope"/> if the type implements that
|
||||
/// abstraction. Resolving via the concrete <see cref="TypeId"/> (rather than asking the
|
||||
/// PortableValue to deserialize directly to <see cref="IExternalRequestEnvelope"/>) is
|
||||
/// required because checkpointed payloads round-trip as JSON which cannot be deserialized
|
||||
/// to an interface; the concrete type populates the deserialization cache so subsequent
|
||||
/// interface assignment succeeds.
|
||||
/// </summary>
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2057:Unrecognized value passed to the parameter of method", Justification = "Higher-layer envelope types are explicitly preserved by the package that defines them.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Higher-layer envelope types are explicitly preserved by the package that defines them.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2073:Members annotated with 'DynamicallyAccessedMembersAttribute' require dynamic access", Justification = "Higher-layer envelope types are explicitly preserved by the package that defines them.")]
|
||||
private static bool TryGetRequestEnvelope(ExternalRequest request, [NotNullWhen(true)] out IExternalRequestEnvelope? envelope)
|
||||
// Resolves the request payload as an envelope using the request types declared by the
|
||||
// workflow's request ports.
|
||||
private bool TryGetRequestEnvelope(ExternalRequest request, [NotNullWhen(true)] out IExternalRequestEnvelope? envelope)
|
||||
=> TryGetRequestEnvelope(request, this._workflow.Ports, out envelope);
|
||||
|
||||
// Returns true and the envelope when the request's port declares a type that implements the
|
||||
// envelope contract and the payload deserializes to it; otherwise returns false so the
|
||||
// request is delivered as ordinary content.
|
||||
internal static bool TryGetRequestEnvelope(ExternalRequest request, IReadOnlyDictionary<string, RequestPort> ports, [NotNullWhen(true)] out IExternalRequestEnvelope? envelope)
|
||||
{
|
||||
envelope = null;
|
||||
|
||||
TypeId requestType = request.PortInfo.RequestType;
|
||||
Type? concreteType = ResolveTypeLenient(requestType);
|
||||
Type? concreteType = ResolveEnvelopeType(request.PortInfo, ports);
|
||||
if (concreteType is null || !typeof(IExternalRequestEnvelope).IsAssignableFrom(concreteType))
|
||||
{
|
||||
return false;
|
||||
@@ -320,28 +314,27 @@ internal sealed class WorkflowSession : AgentSession
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Caches <see cref="ResolveTypeLenient"/> results keyed by <see cref="TypeId"/>.</summary>
|
||||
private static readonly ConcurrentDictionary<TypeId, Type?> s_envelopeTypeCache = new();
|
||||
// Returns the request type declared by the port that owns the request when it matches the
|
||||
// type recorded on the request; otherwise returns null.
|
||||
internal static Type? ResolveEnvelopeType(RequestPortInfo portInfo, IReadOnlyDictionary<string, RequestPort> ports)
|
||||
{
|
||||
if (ports.TryGetValue(portInfo.PortId, out RequestPort? port)
|
||||
&& portInfo.RequestType.IsMatch(port.Request))
|
||||
{
|
||||
return port.Request;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a <see cref="TypeId"/> to a loaded <see cref="Type"/> using partial-name binding,
|
||||
/// which matches any loaded assembly with the same simple name regardless of version. Results
|
||||
/// are cached.
|
||||
/// </summary>
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Higher-layer envelope types are explicitly preserved by the package that defines them.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2057:Unrecognized value passed to the parameter of method", Justification = "Higher-layer envelope types are explicitly preserved by the package that defines them.")]
|
||||
internal static Type? ResolveTypeLenient(TypeId typeId)
|
||||
=> s_envelopeTypeCache.GetOrAdd(typeId, static id =>
|
||||
Type.GetType($"{id.NormalizedTypeName}, {id.SimpleAssemblyName}", throwOnError: false));
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the workflow-facing request content surfaced in response updates.
|
||||
/// </summary>
|
||||
private static AIContent CreateRequestContentForDelivery(ExternalRequest request)
|
||||
private AIContent CreateRequestContentForDelivery(ExternalRequest request)
|
||||
{
|
||||
// If the request payload is a higher-layer envelope (e.g., a declarative
|
||||
// ExternalInputRequest), surface its inner FCC/TARC to the host on the wire.
|
||||
if (TryGetRequestEnvelope(request, out IExternalRequestEnvelope? envelope))
|
||||
if (this.TryGetRequestEnvelope(request, out IExternalRequestEnvelope? envelope))
|
||||
{
|
||||
AIContent? inner = envelope.GetInnerRequestContent();
|
||||
if (inner is ToolApprovalRequestContent toolApprovalRequest)
|
||||
@@ -368,12 +361,12 @@ internal sealed class WorkflowSession : AgentSession
|
||||
/// <summary>
|
||||
/// Rewrites workflow-facing response content back to the original agent-owned content ID.
|
||||
/// </summary>
|
||||
private static object NormalizeResponseContentForDelivery(AIContent content, ExternalRequest request)
|
||||
private object NormalizeResponseContentForDelivery(AIContent content, ExternalRequest request)
|
||||
{
|
||||
// If the request payload is a higher-layer envelope, recover the original
|
||||
// CallId/RequestId from the inner content and ask the envelope to wrap the
|
||||
// response back into its paired response type for delivery to the request port.
|
||||
if (TryGetRequestEnvelope(request, out IExternalRequestEnvelope? envelope))
|
||||
if (this.TryGetRequestEnvelope(request, out IExternalRequestEnvelope? envelope))
|
||||
{
|
||||
AIContent? inner = envelope.GetInnerRequestContent();
|
||||
AIContent payload = (content, inner) switch
|
||||
@@ -484,7 +477,7 @@ internal sealed class WorkflowSession : AgentSession
|
||||
break;
|
||||
|
||||
case RequestInfoEvent requestInfo:
|
||||
AIContent requestContent = CreateRequestContentForDelivery(requestInfo.Request);
|
||||
AIContent requestContent = this.CreateRequestContentForDelivery(requestInfo.Request);
|
||||
|
||||
// Track the pending request so we can convert incoming responses back to ExternalResponse.
|
||||
// External callers respond using the workflow-facing request ID, which is always RequestId.
|
||||
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="WorkflowSession.ResolveTypeLenient(TypeId)"/> resolves a
|
||||
/// <see cref="TypeId"/> to a loaded <see cref="Type"/> even when the stored assembly
|
||||
/// name carries a different <c>Version=</c> than the loaded assembly.
|
||||
/// </summary>
|
||||
public class WorkflowSessionResolveTypeLenientTests
|
||||
{
|
||||
[SuppressMessage("Performance", "CA1812", Justification = "Instantiated via Type.GetType in the production code path under test.")]
|
||||
private sealed class TestEnvelope : IExternalRequestEnvelope
|
||||
{
|
||||
AIContent? IExternalRequestEnvelope.GetInnerRequestContent() => null;
|
||||
|
||||
object IExternalRequestEnvelope.CreateResponse(IList<ChatMessage> messages) => messages;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_ResolveTypeLenient_ResolvesWhenAssemblyNameMatchesLoadedVersion()
|
||||
{
|
||||
Type live = typeof(TestEnvelope);
|
||||
TypeId id = new(live);
|
||||
|
||||
WorkflowSession.ResolveTypeLenient(id).Should().Be(live);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_ResolveTypeLenient_ResolvesAcrossAssemblyVersionMutation()
|
||||
{
|
||||
Type live = typeof(TestEnvelope);
|
||||
string simpleAssemblyName = live.Assembly.GetName().Name!;
|
||||
string mutatedAssemblyName = $"{simpleAssemblyName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null";
|
||||
TypeId mutated = new(mutatedAssemblyName, live.FullName!);
|
||||
|
||||
WorkflowSession.ResolveTypeLenient(mutated).Should().Be(live);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_ResolveTypeLenient_ReturnsNullForUnknownType()
|
||||
{
|
||||
TypeId id = new("Some.Unloaded.Assembly", "Some.Unknown.Type");
|
||||
|
||||
WorkflowSession.ResolveTypeLenient(id).Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_ResolveTypeLenient_ResolvesAcrossGenericArgumentVersionMutation()
|
||||
{
|
||||
Type live = typeof(List<ChatMessage>);
|
||||
string outerSimpleName = live.Assembly.GetName().Name!;
|
||||
string innerSimpleName = typeof(ChatMessage).Assembly.GetName().Name!;
|
||||
string mutatedTypeName = $"System.Collections.Generic.List`1[[Microsoft.Extensions.AI.ChatMessage, {innerSimpleName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null]]";
|
||||
|
||||
TypeId mutated = new(outerSimpleName, mutatedTypeName);
|
||||
|
||||
WorkflowSession.ResolveTypeLenient(mutated).Should().Be(live);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="WorkflowSession"/> recognizes an external request as an envelope
|
||||
/// only when the request's port declares a type that matches the type recorded on the request,
|
||||
/// resolving that type from the live workflow ports rather than the recorded assembly name.
|
||||
/// </summary>
|
||||
public class WorkflowSessionTests
|
||||
{
|
||||
private sealed class TestEnvelope : IExternalRequestEnvelope
|
||||
{
|
||||
AIContent? IExternalRequestEnvelope.GetInnerRequestContent() => null;
|
||||
|
||||
object IExternalRequestEnvelope.CreateResponse(IList<ChatMessage> messages) => messages;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEnvelopeType_ReturnsPortTypeWhenPortDeclaresMatchingType()
|
||||
{
|
||||
Type live = typeof(TestEnvelope);
|
||||
RequestPort port = new("port-1", live, typeof(object));
|
||||
Dictionary<string, RequestPort> ports = new() { [port.Id] = port };
|
||||
RequestPortInfo portInfo = new(new TypeId(live), new TypeId(typeof(object)), port.Id);
|
||||
|
||||
WorkflowSession.ResolveEnvelopeType(portInfo, ports).Should().Be(live);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEnvelopeType_ResolvesAcrossAssemblyVersionMutation()
|
||||
{
|
||||
Type live = typeof(TestEnvelope);
|
||||
RequestPort port = new("port-1", live, typeof(object));
|
||||
Dictionary<string, RequestPort> ports = new() { [port.Id] = port };
|
||||
|
||||
string simpleAssemblyName = live.Assembly.GetName().Name!;
|
||||
string mutatedAssemblyName = $"{simpleAssemblyName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null";
|
||||
RequestPortInfo portInfo = new(new TypeId(mutatedAssemblyName, live.FullName!), new TypeId(typeof(object)), port.Id);
|
||||
|
||||
WorkflowSession.ResolveEnvelopeType(portInfo, ports).Should().Be(live);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEnvelopeType_ResolvesAcrossGenericArgumentVersionMutation()
|
||||
{
|
||||
Type live = typeof(List<ChatMessage>);
|
||||
RequestPort port = new("port-1", live, typeof(object));
|
||||
Dictionary<string, RequestPort> ports = new() { [port.Id] = port };
|
||||
|
||||
string outerSimpleName = live.Assembly.GetName().Name!;
|
||||
string innerSimpleName = typeof(ChatMessage).Assembly.GetName().Name!;
|
||||
string mutatedTypeName = $"System.Collections.Generic.List`1[[Microsoft.Extensions.AI.ChatMessage, {innerSimpleName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null]]";
|
||||
RequestPortInfo portInfo = new(new TypeId(outerSimpleName, mutatedTypeName), new TypeId(typeof(object)), port.Id);
|
||||
|
||||
WorkflowSession.ResolveEnvelopeType(portInfo, ports).Should().Be(live);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEnvelopeType_ReturnsNullWhenPortIdIsUnknown()
|
||||
{
|
||||
Dictionary<string, RequestPort> ports = new()
|
||||
{
|
||||
["port-1"] = new RequestPort("port-1", typeof(TestEnvelope), typeof(object)),
|
||||
};
|
||||
RequestPortInfo portInfo = new(new TypeId(typeof(TestEnvelope)), new TypeId(typeof(object)), "missing-port");
|
||||
|
||||
WorkflowSession.ResolveEnvelopeType(portInfo, ports).Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEnvelopeType_ReturnsNullWhenRecordedTypeDoesNotMatchPortType()
|
||||
{
|
||||
Dictionary<string, RequestPort> ports = new()
|
||||
{
|
||||
["port-1"] = new RequestPort("port-1", typeof(TestEnvelope), typeof(object)),
|
||||
};
|
||||
RequestPortInfo portInfo = new(new TypeId("Some.Unloaded.Assembly", "Some.Unknown.Type"), new TypeId(typeof(object)), "port-1");
|
||||
|
||||
WorkflowSession.ResolveEnvelopeType(portInfo, ports).Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetRequestEnvelope_ReturnsEnvelopeWhenPortDeclaresEnvelopeType()
|
||||
{
|
||||
RequestPort port = new("port-1", typeof(TestEnvelope), typeof(object));
|
||||
Dictionary<string, RequestPort> ports = new() { [port.Id] = port };
|
||||
ExternalRequest request = ExternalRequest.Create(port, new TestEnvelope());
|
||||
|
||||
WorkflowSession.TryGetRequestEnvelope(request, ports, out IExternalRequestEnvelope? envelope).Should().BeTrue();
|
||||
envelope.Should().BeOfType<TestEnvelope>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetRequestEnvelope_ReturnsFalseWhenPortTypeIsNotEnvelope()
|
||||
{
|
||||
RequestPort port = new("port-1", typeof(string), typeof(object));
|
||||
Dictionary<string, RequestPort> ports = new() { [port.Id] = port };
|
||||
ExternalRequest request = ExternalRequest.Create(port, "not-an-envelope");
|
||||
|
||||
WorkflowSession.TryGetRequestEnvelope(request, ports, out IExternalRequestEnvelope? envelope).Should().BeFalse();
|
||||
envelope.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetRequestEnvelope_ReturnsFalseWhenRecordedTypeDoesNotMatchPortType()
|
||||
{
|
||||
RequestPort port = new("port-1", typeof(TestEnvelope), typeof(object));
|
||||
Dictionary<string, RequestPort> ports = new() { [port.Id] = port };
|
||||
RequestPortInfo recordedPortInfo = new(new TypeId("Some.Unloaded.Assembly", "Some.Unknown.Type"), new TypeId(typeof(object)), port.Id);
|
||||
ExternalRequest request = new(recordedPortInfo, "req-1", new PortableValue(new TestEnvelope()));
|
||||
|
||||
WorkflowSession.TryGetRequestEnvelope(request, ports, out IExternalRequestEnvelope? envelope).Should().BeFalse();
|
||||
envelope.Should().BeNull();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user