mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
11
Commits
+6
-5
@@ -209,13 +209,14 @@ WARP.md
|
||||
**/tmpclaude*
|
||||
|
||||
# Azurite storage emulator files
|
||||
*/__azurite_db_blob__.json
|
||||
*/__azurite_db_blob_extent__.json
|
||||
*/__azurite_db_queue__.json
|
||||
*/__azurite_db_queue_extent__.json
|
||||
*/__azurite_db_table__.json
|
||||
*/__azurite_db_blob__.json*
|
||||
*/__azurite_db_blob_extent__.json*
|
||||
*/__azurite_db_queue__.json*
|
||||
*/__azurite_db_queue_extent__.json*
|
||||
*/__azurite_db_table__.json*
|
||||
*/__blobstorage__/
|
||||
*/__queuestorage__/
|
||||
*/AzuriteConfig
|
||||
|
||||
# Azure Functions local settings
|
||||
local.settings.json
|
||||
|
||||
@@ -6,27 +6,16 @@ using Microsoft.Shared.Diagnostics;
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the workflow binding details for an AI agent, including configuration options for agent hosting behaviour.
|
||||
/// Represents the workflow binding details for an AI agent, including configuration options for event emission.
|
||||
/// </summary>
|
||||
/// <param name="Agent">The AI agent.</param>
|
||||
/// <param name="Options">The options for configuring the AI agent host.
|
||||
/// </param>
|
||||
public record AIAgentBinding(AIAgent Agent, AIAgentHostOptions? Options = null)
|
||||
/// <param name="EmitEvents">Specifies whether the agent should emit events. If null, the default behavior is applied.</param>
|
||||
public record AIAgentBinding(AIAgent Agent, bool EmitEvents = false)
|
||||
: ExecutorBinding(Throw.IfNull(Agent).GetDescriptiveId(),
|
||||
(_) => new(new AIAgentHostExecutor(Agent, Options ?? new())),
|
||||
(_) => new(new AIAgentHostExecutor(Agent, EmitEvents)),
|
||||
typeof(AIAgentHostExecutor),
|
||||
Agent)
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the AIAgentBinding class, associating it with the specified AI agent and
|
||||
/// optionally enabling event emission.
|
||||
/// </summary>
|
||||
/// <param name="agent">The AI agent.</param>
|
||||
/// <param name="emitEvents">Specifies whether the agent should emit events. If null, the default behavior is applied.</param>
|
||||
public AIAgentBinding(AIAgent agent, bool emitEvents = false)
|
||||
: this(agent, new AIAgentHostOptions { EmitAgentUpdateEvents = emitEvents })
|
||||
{ }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool IsSharedInstance => false;
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options hosting AI Agents as an Executor.
|
||||
/// </summary>
|
||||
public sealed class AIAgentHostOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether agent streaming update events should be emitted during execution.
|
||||
/// If <see langword="null"/>, the value will be taken from the <see cref="TurnToken"/>
|
||||
/// </summary>
|
||||
public bool? EmitAgentUpdateEvents { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether aggregated agent response events should be emitted during execution.
|
||||
/// </summary>
|
||||
public bool EmitAgentResponseEvents { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether <see cref="UserInputRequestContent"/> should be intercepted and sent
|
||||
/// as a message to the workflow for handling, instead of being raised as a request.
|
||||
/// </summary>
|
||||
public bool InterceptUserInputRequests { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether <see cref="FunctionCallContent"/> without a corresponding
|
||||
/// <see cref="FunctionResultContent"/> should be intercepted and sent as a message to the workflow for handling,
|
||||
/// instead of being raised as a request.
|
||||
/// </summary>
|
||||
public bool InterceptUnterminatedFunctionCalls { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether other messages from other agents should be assigned to the
|
||||
/// <see cref="ChatRole.User"/> role during execution.
|
||||
/// </summary>
|
||||
public bool ReassignOtherAgentsAsUsers { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether incoming messages are automatically forwarded before new messages generated
|
||||
/// by the agent during its turn.
|
||||
/// </summary>
|
||||
public bool ForwardIncomingMessages { get; set; } = true;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -20,29 +19,6 @@ internal static class AIAgentsAbstractionsExtensions
|
||||
RawRepresentation = update.RawRepresentation ?? update,
|
||||
};
|
||||
|
||||
public static ChatMessage ChatAssistantToUserIfNotFromNamed(this ChatMessage message, string agentName)
|
||||
=> message.ChatAssistantToUserIfNotFromNamed(agentName, out _, false);
|
||||
|
||||
private static ChatMessage ChatAssistantToUserIfNotFromNamed(this ChatMessage message, string agentName, out bool changed, bool inplace = true)
|
||||
{
|
||||
changed = false;
|
||||
|
||||
if (message.Role == ChatRole.Assistant &&
|
||||
!StringComparer.Ordinal.Equals(message.AuthorName, agentName) &&
|
||||
message.Contents.All(c => c is TextContent or DataContent or UriContent or UsageContent))
|
||||
{
|
||||
if (!inplace)
|
||||
{
|
||||
message = message.Clone();
|
||||
}
|
||||
|
||||
message.Role = ChatRole.User;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Iterates through <paramref name="messages"/> looking for <see cref="ChatRole.Assistant"/> messages and swapping
|
||||
/// any that have a different <see cref="ChatMessage.AuthorName"/> from <paramref name="targetAgentName"/> to
|
||||
@@ -53,9 +29,11 @@ internal static class AIAgentsAbstractionsExtensions
|
||||
List<ChatMessage>? roleChanged = null;
|
||||
foreach (var m in messages)
|
||||
{
|
||||
m.ChatAssistantToUserIfNotFromNamed(targetAgentName, out bool changed);
|
||||
if (changed)
|
||||
if (m.Role == ChatRole.Assistant &&
|
||||
m.AuthorName != targetAgentName &&
|
||||
m.Contents.All(c => c is TextContent or DataContent or UriContent or UsageContent))
|
||||
{
|
||||
m.Role = ChatRole.User;
|
||||
(roleChanged ??= []).Add(m);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Specialized;
|
||||
@@ -34,28 +35,38 @@ public static partial class AgentWorkflowBuilder
|
||||
|
||||
private static Workflow BuildSequentialCore(string? workflowName, params IEnumerable<AIAgent> agents)
|
||||
{
|
||||
Throw.IfNullOrEmpty(agents);
|
||||
Throw.IfNull(agents);
|
||||
|
||||
// Create a builder that chains the agents together in sequence. The workflow simply begins
|
||||
// with the first agent in the sequence.
|
||||
|
||||
AIAgentHostOptions options = new()
|
||||
WorkflowBuilder? builder = null;
|
||||
ExecutorBinding? previous = null;
|
||||
foreach (var agent in agents)
|
||||
{
|
||||
ReassignOtherAgentsAsUsers = true,
|
||||
ForwardIncomingMessages = true,
|
||||
};
|
||||
AgentRunStreamingExecutor agentExecutor = new(agent, includeInputInOutput: true);
|
||||
|
||||
List<ExecutorBinding> agentExecutors = agents.Select(agent => agent.BindAsExecutor(options)).ToList();
|
||||
if (builder is null)
|
||||
{
|
||||
builder = new WorkflowBuilder(agentExecutor);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.Assert(previous is not null);
|
||||
builder.AddEdge(previous, agentExecutor);
|
||||
}
|
||||
|
||||
ExecutorBinding previous = agentExecutors[0];
|
||||
WorkflowBuilder builder = new(previous);
|
||||
|
||||
foreach (ExecutorBinding next in agentExecutors.Skip(1))
|
||||
{
|
||||
builder.AddEdge(previous, next);
|
||||
previous = next;
|
||||
previous = agentExecutor;
|
||||
}
|
||||
|
||||
if (previous is null)
|
||||
{
|
||||
Throw.ArgumentException(nameof(agents), "At least one agent must be provided to build a sequential workflow.");
|
||||
}
|
||||
|
||||
// Add an ending executor that batches up all messages from the last agent
|
||||
// so that it's published as a single list result.
|
||||
Debug.Assert(builder is not null);
|
||||
|
||||
OutputMessagesExecutor end = new();
|
||||
builder = builder.AddEdge(previous, end).WithOutputFrom(end);
|
||||
if (workflowName is not null)
|
||||
@@ -114,12 +125,9 @@ public static partial class AgentWorkflowBuilder
|
||||
// so that the final accumulator receives a single list of messages from each agent. Otherwise, the
|
||||
// accumulator would not be able to determine what came from what agent, as there's currently no
|
||||
// provenance tracking exposed in the workflow context passed to a handler.
|
||||
|
||||
ExecutorBinding[] agentExecutors = (from agent in agents
|
||||
select agent.BindAsExecutor(new AIAgentHostOptions() { ReassignOtherAgentsAsUsers = true })).ToArray();
|
||||
ExecutorBinding[] accumulators = [.. from agent in agentExecutors select (ExecutorBinding)new AggregateTurnMessagesExecutor($"Batcher/{agent.Id}")];
|
||||
ExecutorBinding[] agentExecutors = (from agent in agents select (ExecutorBinding)new AgentRunStreamingExecutor(agent, includeInputInOutput: false)).ToArray();
|
||||
ExecutorBinding[] accumulators = [.. from agent in agentExecutors select (ExecutorBinding)new CollectChatMessagesExecutor($"Batcher/{agent.Id}")];
|
||||
builder.AddFanOutEdge(start, agentExecutors);
|
||||
|
||||
for (int i = 0; i < agentExecutors.Length; i++)
|
||||
{
|
||||
builder.AddEdge(agentExecutors[i], accumulators[i]);
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -19,12 +18,6 @@ public class ChatProtocolExecutorOptions
|
||||
/// If set, the executor will accept string messages and convert them to chat messages with this role.
|
||||
/// </summary>
|
||||
public ChatRole? StringMessageChatRole { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the executor should automatically send the <see cref="TurnToken"/>
|
||||
/// after returning from <see cref="ChatProtocolExecutor.TakeTurnAsync(List{ChatMessage}, IWorkflowContext, bool?, CancellationToken)"/>
|
||||
/// </summary>
|
||||
public bool AutoSendTurnToken { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -33,8 +26,8 @@ public class ChatProtocolExecutorOptions
|
||||
/// </summary>
|
||||
public abstract class ChatProtocolExecutor : StatefulExecutor<List<ChatMessage>>
|
||||
{
|
||||
internal static readonly Func<List<ChatMessage>> s_initFunction = () => [];
|
||||
private readonly ChatProtocolExecutorOptions _options;
|
||||
private static readonly Func<List<ChatMessage>> s_initFunction = () => [];
|
||||
private readonly ChatRole? _stringMessageChatRole;
|
||||
|
||||
private static readonly StatefulExecutorOptions s_baseExecutorOptions = new()
|
||||
{
|
||||
@@ -51,28 +44,16 @@ public abstract class ChatProtocolExecutor : StatefulExecutor<List<ChatMessage>>
|
||||
protected ChatProtocolExecutor(string id, ChatProtocolExecutorOptions? options = null, bool declareCrossRunShareable = false)
|
||||
: base(id, () => [], s_baseExecutorOptions, declareCrossRunShareable)
|
||||
{
|
||||
this._options = options ?? new();
|
||||
this._stringMessageChatRole = options?.StringMessageChatRole;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether string-based messages are supported by this <see cref="ChatProtocolExecutor"/>.
|
||||
/// </summary>
|
||||
[MemberNotNullWhen(true, nameof(StringMessageChatRole))]
|
||||
protected bool SupportsStringMessage => this.StringMessageChatRole.HasValue;
|
||||
|
||||
/// <inheritdoc cref="ChatProtocolExecutorOptions.StringMessageChatRole"/>
|
||||
protected ChatRole? StringMessageChatRole => this._options.StringMessageChatRole;
|
||||
|
||||
/// <inheritdoc cref="ChatProtocolExecutorOptions.AutoSendTurnToken"/>
|
||||
protected bool AutoSendTurnToken => this._options.AutoSendTurnToken;
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
|
||||
{
|
||||
if (this.SupportsStringMessage)
|
||||
if (this._stringMessageChatRole.HasValue)
|
||||
{
|
||||
routeBuilder = routeBuilder.AddHandler<string>(
|
||||
(message, context) => this.AddMessageAsync(new(this.StringMessageChatRole.Value, message), context));
|
||||
(message, context) => this.AddMessageAsync(new(this._stringMessageChatRole.Value, message), context));
|
||||
}
|
||||
|
||||
return routeBuilder.AddHandler<ChatMessage>(this.AddMessageAsync)
|
||||
@@ -136,10 +117,7 @@ public abstract class ChatProtocolExecutor : StatefulExecutor<List<ChatMessage>>
|
||||
await this.TakeTurnAsync(maybePendingMessages ?? s_initFunction(), context, token.EmitEvents, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (this.AutoSendTurnToken)
|
||||
{
|
||||
await context.SendMessageAsync(token, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
await context.SendMessageAsync(token, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Rerun the initialStateFactory to reset the state to empty list. (We could return the empty list directly,
|
||||
// but this is more consistent if the initial state factory becomes more complex.)
|
||||
@@ -147,28 +125,6 @@ public abstract class ChatProtocolExecutor : StatefulExecutor<List<ChatMessage>>
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes the current set of turn messages using the specified asynchronous processing function.
|
||||
/// </summary>
|
||||
/// <remarks>If the provided list of chat messages is null, an initial empty list is supplied to the
|
||||
/// processing function. If the processing function returns null, an empty list is used as the result.</remarks>
|
||||
/// <param name="processFunc">A delegate that asynchronously processes a list of chat messages within the given workflow context and
|
||||
/// cancellation token, returning the processed list of chat messages or null.</param>
|
||||
/// <param name="context">The workflow context in which the messages are processed.</param>
|
||||
/// <param name="cancellationToken">A token that can be used to cancel the asynchronous operation.</param>
|
||||
/// <returns>A ValueTask that represents the asynchronous operation. The result contains the processed list of chat messages,
|
||||
/// or an empty list if the processing function returns null.</returns>
|
||||
protected ValueTask ProcessTurnMessagesAsync(Func<List<ChatMessage>, IWorkflowContext, CancellationToken, ValueTask<List<ChatMessage>?>> processFunc, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return this.InvokeWithStateAsync(InvokeProcessFuncAsync, context, cancellationToken: cancellationToken);
|
||||
|
||||
async ValueTask<List<ChatMessage>?> InvokeProcessFuncAsync(List<ChatMessage>? maybePendingMessages, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return (await processFunc(maybePendingMessages ?? s_initFunction(), context, cancellationToken).ConfigureAwait(false))
|
||||
?? s_initFunction();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When overridden in a derived class, processes the accumulated chat messages for a single turn.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
@@ -13,7 +12,7 @@ internal sealed class EdgeMap
|
||||
{
|
||||
private readonly Dictionary<EdgeId, EdgeRunner> _edgeRunners = [];
|
||||
private readonly Dictionary<EdgeId, IStatefulEdgeRunner> _statefulRunners = [];
|
||||
private readonly ConcurrentDictionary<string, ResponseEdgeRunner> _portEdgeRunners;
|
||||
private readonly Dictionary<string, ResponseEdgeRunner> _portEdgeRunners;
|
||||
|
||||
private readonly ResponseEdgeRunner _inputRunner;
|
||||
private readonly IStepTracer? _stepTracer;
|
||||
@@ -52,16 +51,12 @@ internal sealed class EdgeMap
|
||||
}
|
||||
}
|
||||
|
||||
this._portEdgeRunners = new();
|
||||
foreach (RequestPort port in workflowPorts)
|
||||
{
|
||||
if (!this.TryRegisterPort(runContext, port.Id, port))
|
||||
{
|
||||
throw new InvalidOperationException($"Duplicate port ID detected: {port.Id}");
|
||||
}
|
||||
}
|
||||
this._portEdgeRunners = workflowPorts.ToDictionary(
|
||||
port => port.Id,
|
||||
port => ResponseEdgeRunner.ForPort(runContext, port)
|
||||
);
|
||||
|
||||
this._inputRunner = new ResponseEdgeRunner(runContext, startExecutorId, "");
|
||||
this._inputRunner = new ResponseEdgeRunner(runContext, startExecutorId);
|
||||
this._stepTracer = stepTracer;
|
||||
}
|
||||
|
||||
@@ -76,9 +71,6 @@ internal sealed class EdgeMap
|
||||
return edgeRunner.ChaseEdgeAsync(message, this._stepTracer);
|
||||
}
|
||||
|
||||
public bool TryRegisterPort(IRunnerContext runContext, string executorId, RequestPort port)
|
||||
=> this._portEdgeRunners.TryAdd(port.Id, ResponseEdgeRunner.ForPort(runContext, executorId, port));
|
||||
|
||||
public ValueTask<DeliveryMapping?> PrepareDeliveryForInputAsync(MessageEnvelope message)
|
||||
{
|
||||
return this._inputRunner.ChaseEdgeAsync(message, this._stepTracer);
|
||||
|
||||
@@ -12,6 +12,6 @@ internal interface IRunnerContext : IExternalRequestSink, ISuperStepJoinContext
|
||||
ValueTask SendMessageAsync(string sourceId, object message, string? targetId = null, CancellationToken cancellationToken = default);
|
||||
|
||||
ValueTask<StepContext> AdvanceAsync(CancellationToken cancellationToken = default);
|
||||
IWorkflowContext BindWorkflowContext(string executorId, Dictionary<string, string>? traceContext = null);
|
||||
IWorkflowContext Bind(string executorId, Dictionary<string, string>? traceContext = null);
|
||||
ValueTask<Executor> EnsureExecutorAsync(string executorId, IStepTracer? tracer, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -8,19 +8,17 @@ using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Execution;
|
||||
|
||||
internal sealed class ResponseEdgeRunner(IRunnerContext runContext, string executorId, string sinkId)
|
||||
internal sealed class ResponseEdgeRunner(IRunnerContext runContext, string sinkId)
|
||||
: EdgeRunner<string>(runContext, sinkId)
|
||||
{
|
||||
public static ResponseEdgeRunner ForPort(IRunnerContext runContext, string executorId, RequestPort port)
|
||||
public static ResponseEdgeRunner ForPort(IRunnerContext runContext, RequestPort port)
|
||||
{
|
||||
Throw.IfNull(port);
|
||||
|
||||
// The port is an request port, so we can use the port's ID as the sink ID.
|
||||
return new ResponseEdgeRunner(runContext, executorId, port.Id);
|
||||
return new ResponseEdgeRunner(runContext, port.Id);
|
||||
}
|
||||
|
||||
public string ExecutorId => executorId;
|
||||
|
||||
protected internal override async ValueTask<DeliveryMapping?> ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer)
|
||||
{
|
||||
Debug.Assert(envelope.IsExternal, "Input edges should only be chased from external input");
|
||||
@@ -29,7 +27,7 @@ internal sealed class ResponseEdgeRunner(IRunnerContext runContext, string execu
|
||||
activity?
|
||||
.SetTag(Tags.EdgeGroupType, nameof(ResponseEdgeRunner))
|
||||
.SetTag(Tags.MessageSourceId, envelope.SourceId)
|
||||
.SetTag(Tags.MessageTargetId, $"{this.ExecutorId}[{this.EdgeData}]");
|
||||
.SetTag(Tags.MessageTargetId, this.EdgeData);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -50,5 +48,5 @@ internal sealed class ResponseEdgeRunner(IRunnerContext runContext, string execu
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask<Executor> FindExecutorAsync(IStepTracer? tracer) => await this.RunContext.EnsureExecutorAsync(this.ExecutorId, tracer).ConfigureAwait(false);
|
||||
private async ValueTask<Executor> FindExecutorAsync(IStepTracer? tracer) => await this.RunContext.EnsureExecutorAsync(this.EdgeData, tracer).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace Microsoft.Agents.AI.Workflows;
|
||||
/// <summary>
|
||||
/// A component that processes messages in a <see cref="Workflow"/>.
|
||||
/// </summary>
|
||||
[DebuggerDisplay("{GetType().Name}[{Id}]")]
|
||||
[DebuggerDisplay("{GetType().Name}{Id}")]
|
||||
public abstract class Executor : IIdentified
|
||||
{
|
||||
/// <summary>
|
||||
@@ -63,24 +63,6 @@ public abstract class Executor : IIdentified
|
||||
/// </summary>
|
||||
protected abstract RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder);
|
||||
|
||||
internal void Configure(IExternalRequestContext externalRequestContext)
|
||||
{
|
||||
// TODO: This is an unfortunate pattern (pending the ability to rework the Configure APIs a bit):
|
||||
// new()
|
||||
// >>> will throw InvalidOperationException if Configure() is not invoked when using PortHandlers
|
||||
// .Configure()
|
||||
// >>> only usable now
|
||||
// The fix would be to change the API surface of Executor to have Configure return the contract that the workflow
|
||||
// will use to invoke the executor (currently the MessageRouter). (Ideally we would rename Executor to Node or similar,
|
||||
// and the actual Executor class will represent that Contract object)
|
||||
// Not a terrible issue right now because only InProcessExecution exists right now, and the InProccessRunContext centralizes
|
||||
// executor instantiation in EnsureExecutorAsync.
|
||||
this.Router = this.CreateRouter(externalRequestContext);
|
||||
}
|
||||
|
||||
private MessageRouter CreateRouter(IExternalRequestContext? externalRequestContext = null)
|
||||
=> this.ConfigureRoutes(new RouteBuilder(externalRequestContext)).Build();
|
||||
|
||||
/// <summary>
|
||||
/// Perform any asynchronous initialization required by the executor. This method is called once per executor instance,
|
||||
/// </summary>
|
||||
@@ -117,15 +99,12 @@ public abstract class Executor : IIdentified
|
||||
{
|
||||
if (field is null)
|
||||
{
|
||||
field = this.CreateRouter();
|
||||
RouteBuilder routeBuilder = this.ConfigureRoutes(new RouteBuilder());
|
||||
field = routeBuilder.Build();
|
||||
}
|
||||
|
||||
return field;
|
||||
}
|
||||
private set
|
||||
{
|
||||
field = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -419,18 +419,9 @@ public static class ExecutorBindingExtensions
|
||||
/// <param name="agent">The agent instance.</param>
|
||||
/// <param name="emitEvents">Specifies whether the agent should emit streaming events.</param>
|
||||
/// <returns>An <see cref="AIAgentBinding"/> instance that wraps the provided agent.</returns>
|
||||
public static ExecutorBinding BindAsExecutor(this AIAgent agent, bool emitEvents)
|
||||
public static ExecutorBinding BindAsExecutor(this AIAgent agent, bool emitEvents = false)
|
||||
=> new AIAgentBinding(agent, emitEvents);
|
||||
|
||||
/// <summary>
|
||||
/// Configure an <see cref="AIAgent"/> as an executor for use in a workflow.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent instance.</param>
|
||||
/// <param name="options">Optional configuration options for the AI agent executor. If null, default options are used.</param>
|
||||
/// <returns>An <see cref="AIAgentBinding"/> instance that wraps the provided agent.</returns>
|
||||
public static ExecutorBinding BindAsExecutor(this AIAgent agent, AIAgentHostOptions? options = null)
|
||||
=> new AIAgentBinding(agent, options);
|
||||
|
||||
/// <summary>
|
||||
/// Configure a <see cref="RequestPort"/> as an executor for use in a workflow.
|
||||
/// </summary>
|
||||
|
||||
@@ -43,13 +43,4 @@ public record ExternalResponse(RequestPortInfo PortInfo, string RequestId, Porta
|
||||
/// <param name="targetType">The type to which the data should be cast or converted.</param>
|
||||
/// <returns>The data cast to the specified type, or null if the data cannot be cast to the specified type.</returns>
|
||||
public object? DataAs(Type targetType) => this.Data.AsType(targetType);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to retrieve the underlying data as the specified type.
|
||||
/// </summary>
|
||||
/// <param name="targetType">The type to which the data should be cast or converted.</param>
|
||||
/// <param name="value">When this method returns <see langword="true"/>, contains the value of type
|
||||
/// <paramref name="targetType"/> if the data is available and compatible.</param>
|
||||
/// <returns>true if the data is present and can be cast to <paramref name="targetType"/>; otherwise, false.</returns>
|
||||
public bool DataIs(Type targetType, [NotNullWhen(true)] out object? value) => this.Data.IsType(targetType, out value);
|
||||
}
|
||||
|
||||
@@ -50,14 +50,7 @@ public sealed class GroupChatWorkflowBuilder
|
||||
public Workflow Build()
|
||||
{
|
||||
AIAgent[] agents = this._participants.ToArray();
|
||||
|
||||
AIAgentHostOptions options = new()
|
||||
{
|
||||
ReassignOtherAgentsAsUsers = true,
|
||||
ForwardIncomingMessages = true
|
||||
};
|
||||
|
||||
Dictionary<AIAgent, ExecutorBinding> agentMap = agents.ToDictionary(a => a, a => a.BindAsExecutor(options));
|
||||
Dictionary<AIAgent, ExecutorBinding> agentMap = agents.ToDictionary(a => a, a => (ExecutorBinding)new AgentRunStreamingExecutor(a, includeInputInOutput: true));
|
||||
|
||||
Func<string, string, ValueTask<Executor>> groupChatHostFactory =
|
||||
(id, runId) => new(new GroupChatHost(id, agents, agentMap, this._managerFactory));
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows.Execution;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
internal interface IExternalRequestContext
|
||||
{
|
||||
IExternalRequestSink RegisterPort(RequestPort port);
|
||||
}
|
||||
@@ -200,7 +200,7 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle
|
||||
await executor.ExecuteAsync(
|
||||
envelope.Message,
|
||||
envelope.MessageType,
|
||||
this.RunContext.BindWorkflowContext(receiverId, envelope.TraceContext),
|
||||
this.RunContext.Bind(receiverId, envelope.TraceContext),
|
||||
cancellationToken
|
||||
).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -71,16 +71,6 @@ internal sealed class InProcessRunnerContext : IRunnerContext
|
||||
this.OutgoingEvents = outgoingEvents;
|
||||
}
|
||||
|
||||
public IExternalRequestSink RegisterPort(string executorId, RequestPort port)
|
||||
{
|
||||
if (!this._edgeMap.TryRegisterPort(this, executorId, port))
|
||||
{
|
||||
throw new InvalidOperationException($"A port with ID {port.Id} already exists.");
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public async ValueTask<Executor> EnsureExecutorAsync(string executorId, IStepTracer? tracer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.CheckEnded();
|
||||
@@ -94,9 +84,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext
|
||||
}
|
||||
|
||||
Executor executor = await registration.CreateInstanceAsync(this._runId).ConfigureAwait(false);
|
||||
executor.Configure(this.BindExternalRequestContext(executorId));
|
||||
|
||||
await executor.InitializeAsync(this.BindWorkflowContext(executorId), cancellationToken: cancellationToken)
|
||||
await executor.InitializeAsync(this.Bind(executorId), cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
tracer?.TraceActivated(executorId);
|
||||
@@ -245,16 +233,10 @@ internal sealed class InProcessRunnerContext : IRunnerContext
|
||||
}
|
||||
}
|
||||
|
||||
public IExternalRequestContext BindExternalRequestContext(string executorId)
|
||||
public IWorkflowContext Bind(string executorId, Dictionary<string, string>? traceContext = null)
|
||||
{
|
||||
this.CheckEnded();
|
||||
return new BoundExternalRequestContext(this, executorId);
|
||||
}
|
||||
|
||||
public IWorkflowContext BindWorkflowContext(string executorId, Dictionary<string, string>? traceContext = null)
|
||||
{
|
||||
this.CheckEnded();
|
||||
return new BoundWorkflowContext(this, executorId, traceContext);
|
||||
return new BoundContext(this, executorId, traceContext);
|
||||
}
|
||||
|
||||
public ValueTask PostAsync(ExternalRequest request)
|
||||
@@ -278,17 +260,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext
|
||||
|
||||
internal StateManager StateManager { get; } = new();
|
||||
|
||||
private sealed class BoundExternalRequestContext(
|
||||
InProcessRunnerContext RunnerContext,
|
||||
string ExecutorId) : IExternalRequestContext
|
||||
{
|
||||
public IExternalRequestSink RegisterPort(RequestPort port)
|
||||
{
|
||||
return RunnerContext.RegisterPort(ExecutorId, port);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BoundWorkflowContext(
|
||||
private sealed class BoundContext(
|
||||
InProcessRunnerContext RunnerContext,
|
||||
string ExecutorId,
|
||||
Dictionary<string, string>? traceContext) : IWorkflowContext
|
||||
@@ -340,7 +312,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext
|
||||
async Task InvokeCheckpointingAsync(Task<Executor> executorTask)
|
||||
{
|
||||
Executor executor = await executorTask.ConfigureAwait(false);
|
||||
await executor.OnCheckpointingAsync(this.BindWorkflowContext(executor.Id), cancellationToken).ConfigureAwait(false);
|
||||
await executor.OnCheckpointingAsync(this.Bind(executor.Id), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,7 +325,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext
|
||||
async Task InvokeCheckpointRestoredAsync(Task<Executor> executorTask)
|
||||
{
|
||||
Executor executor = await executorTask.ConfigureAwait(false);
|
||||
await executor.OnCheckpointRestoredAsync(this.BindWorkflowContext(executor.Id), cancellationToken).ConfigureAwait(false);
|
||||
await executor.OnCheckpointRestoredAsync(this.Bind(executor.Id), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<NoWarn>$(NoWarn);MEAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Execution;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
internal class PortBinding(RequestPort port, IExternalRequestSink sink)
|
||||
{
|
||||
public RequestPort Port => port;
|
||||
public IExternalRequestSink Sink => sink;
|
||||
|
||||
public ValueTask PostRequestAsync<TRequest>(TRequest request, string? requestId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ExternalRequest externalRequest = ExternalRequest.Create(this.Port, request, requestId);
|
||||
return this.Sink.PostAsync(externalRequest);
|
||||
}
|
||||
}
|
||||
@@ -22,14 +22,6 @@ using MessageHandlerF =
|
||||
System.Threading.Tasks.ValueTask<Microsoft.Agents.AI.Workflows.Execution.CallResult>
|
||||
>;
|
||||
|
||||
using PortHandlerF =
|
||||
System.Func<
|
||||
Microsoft.Agents.AI.Workflows.ExternalResponse, // message
|
||||
Microsoft.Agents.AI.Workflows.IWorkflowContext, // context
|
||||
System.Threading.CancellationToken, // cancellation
|
||||
System.Threading.Tasks.ValueTask<Microsoft.Agents.AI.Workflows.ExternalResponse?>
|
||||
>;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
@@ -40,17 +32,10 @@ namespace Microsoft.Agents.AI.Workflows;
|
||||
/// </remarks>
|
||||
public class RouteBuilder
|
||||
{
|
||||
private readonly IExternalRequestContext? _externalRequestContext;
|
||||
private readonly Dictionary<Type, MessageHandlerF> _typedHandlers = [];
|
||||
private readonly Dictionary<Type, Type> _outputTypes = [];
|
||||
private readonly Dictionary<string, PortHandlerF> _portHandlers = [];
|
||||
private CatchAllF? _catchAll;
|
||||
|
||||
internal RouteBuilder(IExternalRequestContext? externalRequestContext)
|
||||
{
|
||||
this._externalRequestContext = externalRequestContext;
|
||||
}
|
||||
|
||||
internal RouteBuilder AddHandlerInternal(Type messageType, MessageHandlerF handler, Type? outputType, bool overwrite = false)
|
||||
{
|
||||
Throw.IfNull(messageType);
|
||||
@@ -117,60 +102,6 @@ public class RouteBuilder
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a port and associated handler for external requests originating from the executor. This generates a PortBinding that can be used to
|
||||
/// submit requests through to the workflow Run call.
|
||||
/// </summary>
|
||||
/// <typeparam name="TRequest">The type of request messages that will be sent through this port.</typeparam>
|
||||
/// <typeparam name="TResponse">The type of response messages that will be sent through this port.</typeparam>
|
||||
/// <param name="id">A unique identifier for the port.</param>
|
||||
/// <param name="handler">A delegate that processes messages of type <typeparamref name="TResponse"/> within the workflow context. The
|
||||
/// delegate is invoked for each incoming response to requests through this port.</param>
|
||||
/// <param name="portBinding">A <see cref="PortBinding"/> representing this port registration providing a means to submit requests.</param>
|
||||
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified response; if a port with this id is not
|
||||
/// this will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of additional handlers or route
|
||||
/// options.</returns>
|
||||
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
|
||||
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
|
||||
internal RouteBuilder AddPortHandler<TRequest, TResponse>(string id, Func<TResponse, IWorkflowContext, CancellationToken, ValueTask> handler, out PortBinding portBinding, bool overwrite = false)
|
||||
{
|
||||
if (this._externalRequestContext == null)
|
||||
{
|
||||
throw new InvalidOperationException("An external request context is required to register port handlers.");
|
||||
}
|
||||
|
||||
RequestPort port = RequestPort.Create<TRequest, TResponse>(id);
|
||||
IExternalRequestSink sink = this._externalRequestContext!.RegisterPort(port);
|
||||
portBinding = new(port, sink);
|
||||
|
||||
if (this._portHandlers.ContainsKey(id) == overwrite)
|
||||
{
|
||||
this._portHandlers[id] = InvokeHandlerAsync;
|
||||
}
|
||||
else if (overwrite)
|
||||
{
|
||||
throw new InvalidOperationException($"A handler for port id {id} is not registered (overwrite = true).");
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException($"A handler for port id {id} is already registered (overwrite = false).");
|
||||
}
|
||||
|
||||
return this;
|
||||
|
||||
async ValueTask<ExternalResponse?> InvokeHandlerAsync(ExternalResponse response, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!response.DataIs(out TResponse? typedResponse))
|
||||
{
|
||||
throw new InvalidOperationException($"Received response data is not of expected type {typeof(TResponse).FullName} for port {port.Id}.");
|
||||
}
|
||||
|
||||
await handler(typedResponse, context, cancellationToken).ConfigureAwait(false);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a handler for messages of the specified input type in the workflow route.
|
||||
/// </summary>
|
||||
@@ -180,12 +111,10 @@ public class RouteBuilder
|
||||
/// <typeparam name="TInput"></typeparam>
|
||||
/// <param name="handler">A delegate that processes messages of type <typeparamref name="TInput"/> within the workflow context. The
|
||||
/// delegate is invoked for each incoming message of the specified type.</param>
|
||||
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
|
||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
||||
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the specified input type; otherwise, <see
|
||||
/// langword="false"/> to preserve the existing handler.</param>
|
||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of additional handlers or route
|
||||
/// options.</returns>
|
||||
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
|
||||
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
|
||||
public RouteBuilder AddHandler<TInput>(Action<TInput, IWorkflowContext, CancellationToken> handler, bool overwrite = false)
|
||||
{
|
||||
Throw.IfNull(handler);
|
||||
@@ -208,12 +137,10 @@ public class RouteBuilder
|
||||
/// <typeparam name="TInput"></typeparam>
|
||||
/// <param name="handler">A delegate that processes messages of type <typeparamref name="TInput"/> within the workflow context. The
|
||||
/// delegate is invoked for each incoming message of the specified type.</param>
|
||||
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
|
||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
||||
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the specified input type; otherwise, <see
|
||||
/// langword="false"/> to preserve the existing handler.</param>
|
||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of additional handlers or route
|
||||
/// options.</returns>
|
||||
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
|
||||
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
|
||||
public RouteBuilder AddHandler<TInput>(Action<TInput, IWorkflowContext> handler, bool overwrite = false)
|
||||
{
|
||||
Throw.IfNull(handler);
|
||||
@@ -236,12 +163,10 @@ public class RouteBuilder
|
||||
/// <typeparam name="TInput"></typeparam>
|
||||
/// <param name="handler">A delegate that processes messages of type <typeparamref name="TInput"/> within the workflow context. The
|
||||
/// delegate is invoked for each incoming message of the specified type.</param>
|
||||
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
|
||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
||||
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the specified input type; otherwise, <see
|
||||
/// langword="false"/> to preserve the existing handler.</param>
|
||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of additional handlers or route
|
||||
/// options.</returns>
|
||||
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
|
||||
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
|
||||
public RouteBuilder AddHandler<TInput>(Func<TInput, IWorkflowContext, CancellationToken, ValueTask> handler, bool overwrite = false)
|
||||
{
|
||||
Throw.IfNull(handler);
|
||||
@@ -264,12 +189,10 @@ public class RouteBuilder
|
||||
/// <typeparam name="TInput"></typeparam>
|
||||
/// <param name="handler">A delegate that processes messages of type <typeparamref name="TInput"/> within the workflow context. The
|
||||
/// delegate is invoked for each incoming message of the specified type.</param>
|
||||
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
|
||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
||||
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the specified input type; otherwise, <see
|
||||
/// langword="false"/> to preserve the existing handler.</param>
|
||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of additional handlers or route
|
||||
/// options.</returns>
|
||||
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
|
||||
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
|
||||
public RouteBuilder AddHandler<TInput>(Func<TInput, IWorkflowContext, ValueTask> handler, bool overwrite = false)
|
||||
{
|
||||
Throw.IfNull(handler);
|
||||
@@ -293,11 +216,9 @@ public class RouteBuilder
|
||||
/// <typeparam name="TResult">The type of result produced by the handler.</typeparam>
|
||||
/// <param name="handler">A function that processes messages of type <typeparamref name="TInput"/> within the workflow context and returns
|
||||
/// a <see cref="ValueTask{TResult}"/> representing the asynchronous result.</param>
|
||||
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
|
||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
||||
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
|
||||
/// preserve existing handlers.</param>
|
||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
|
||||
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
|
||||
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
|
||||
public RouteBuilder AddHandler<TInput, TResult>(Func<TInput, IWorkflowContext, CancellationToken, TResult> handler, bool overwrite = false)
|
||||
{
|
||||
Throw.IfNull(handler);
|
||||
@@ -321,11 +242,9 @@ public class RouteBuilder
|
||||
/// <typeparam name="TResult">The type of result produced by the handler.</typeparam>
|
||||
/// <param name="handler">A function that processes messages of type <typeparamref name="TInput"/> within the workflow context and returns
|
||||
/// a <see cref="ValueTask{TResult}"/> representing the asynchronous result.</param>
|
||||
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
|
||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
||||
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
|
||||
/// preserve existing handlers.</param>
|
||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
|
||||
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
|
||||
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
|
||||
public RouteBuilder AddHandler<TInput, TResult>(Func<TInput, IWorkflowContext, TResult> handler, bool overwrite = false)
|
||||
{
|
||||
Throw.IfNull(handler);
|
||||
@@ -349,11 +268,9 @@ public class RouteBuilder
|
||||
/// <typeparam name="TResult">The type of result produced by the handler.</typeparam>
|
||||
/// <param name="handler">A function that processes messages of type <typeparamref name="TInput"/> within the workflow context and returns
|
||||
/// a <see cref="ValueTask{TResult}"/> representing the asynchronous result.</param>
|
||||
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
|
||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
||||
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
|
||||
/// preserve existing handlers.</param>
|
||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
|
||||
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
|
||||
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
|
||||
public RouteBuilder AddHandler<TInput, TResult>(Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TResult>> handler, bool overwrite = false)
|
||||
{
|
||||
Throw.IfNull(handler);
|
||||
@@ -362,7 +279,7 @@ public class RouteBuilder
|
||||
|
||||
async ValueTask<CallResult> WrappedHandlerAsync(object message, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
TResult result = await handler((TInput)message, context, cancellationToken).ConfigureAwait(false);
|
||||
TResult result = await handler.Invoke((TInput)message, context, cancellationToken).ConfigureAwait(false);
|
||||
return CallResult.ReturnResult(result);
|
||||
}
|
||||
}
|
||||
@@ -377,11 +294,9 @@ public class RouteBuilder
|
||||
/// <typeparam name="TResult">The type of result produced by the handler.</typeparam>
|
||||
/// <param name="handler">A function that processes messages of type <typeparamref name="TInput"/> within the workflow context and returns
|
||||
/// a <see cref="ValueTask{TResult}"/> representing the asynchronous result.</param>
|
||||
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
|
||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
||||
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
|
||||
/// preserve existing handlers.</param>
|
||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
|
||||
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
|
||||
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
|
||||
public RouteBuilder AddHandler<TInput, TResult>(Func<TInput, IWorkflowContext, ValueTask<TResult>> handler, bool overwrite = false)
|
||||
{
|
||||
Throw.IfNull(handler);
|
||||
@@ -415,11 +330,9 @@ public class RouteBuilder
|
||||
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
|
||||
/// <param name="handler">A function that processes messages wrapped as <see cref="PortableValue"/> within the
|
||||
/// workflow context. The delegate is invoked for each incoming message not otherwise handled.</param>
|
||||
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
|
||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
||||
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
|
||||
/// preserve existing handlers.</param>
|
||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
|
||||
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
|
||||
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
|
||||
public RouteBuilder AddCatchAll(Func<PortableValue, IWorkflowContext, CancellationToken, ValueTask> handler, bool overwrite = false)
|
||||
{
|
||||
Throw.IfNull(handler);
|
||||
@@ -441,11 +354,9 @@ public class RouteBuilder
|
||||
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
|
||||
/// <param name="handler">A function that processes messages wrapped as <see cref="PortableValue"/> within the
|
||||
/// workflow context. The delegate is invoked for each incoming message not otherwise handled.</param>
|
||||
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
|
||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
||||
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
|
||||
/// preserve existing handlers.</param>
|
||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
|
||||
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
|
||||
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
|
||||
public RouteBuilder AddCatchAll(Func<PortableValue, IWorkflowContext, ValueTask> handler, bool overwrite = false)
|
||||
{
|
||||
Throw.IfNull(handler);
|
||||
@@ -467,11 +378,9 @@ public class RouteBuilder
|
||||
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
|
||||
/// <param name="handler">A function that processes messages wrapped as <see cref="PortableValue"/> within the
|
||||
/// workflow context and returns a <see cref="ValueTask{TResult}"/> representing the asynchronous result.</param>
|
||||
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
|
||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
||||
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
|
||||
/// preserve existing handlers.</param>
|
||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
|
||||
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
|
||||
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
|
||||
public RouteBuilder AddCatchAll<TResult>(Func<PortableValue, IWorkflowContext, CancellationToken, ValueTask<TResult>> handler, bool overwrite = false)
|
||||
{
|
||||
Throw.IfNull(handler);
|
||||
@@ -493,11 +402,9 @@ public class RouteBuilder
|
||||
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
|
||||
/// <param name="handler">A function that processes messages wrapped as <see cref="PortableValue"/> within the
|
||||
/// workflow context and returns a <see cref="ValueTask{TResult}"/> representing the asynchronous result.</param>
|
||||
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
|
||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
||||
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
|
||||
/// preserve existing handlers.</param>
|
||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
|
||||
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
|
||||
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
|
||||
public RouteBuilder AddCatchAll<TResult>(Func<PortableValue, IWorkflowContext, ValueTask<TResult>> handler, bool overwrite = false)
|
||||
{
|
||||
Throw.IfNull(handler);
|
||||
@@ -519,11 +426,9 @@ public class RouteBuilder
|
||||
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
|
||||
/// <param name="handler">A function that processes messages wrapped as <see cref="PortableValue"/> within the
|
||||
/// workflow context. The delegate is invoked for each incoming message not otherwise handled.</param>
|
||||
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
|
||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
||||
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
|
||||
/// preserve existing handlers.</param>
|
||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
|
||||
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
|
||||
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
|
||||
public RouteBuilder AddCatchAll(Action<PortableValue, IWorkflowContext, CancellationToken> handler, bool overwrite = false)
|
||||
{
|
||||
Throw.IfNull(handler);
|
||||
@@ -545,11 +450,9 @@ public class RouteBuilder
|
||||
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
|
||||
/// <param name="handler">A function that processes messages wrapped as <see cref="PortableValue"/> within the
|
||||
/// workflow context. The delegate is invoked for each incoming message not otherwise handled.</param>
|
||||
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
|
||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
||||
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
|
||||
/// preserve existing handlers.</param>
|
||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
|
||||
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
|
||||
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
|
||||
public RouteBuilder AddCatchAll(Action<PortableValue, IWorkflowContext> handler, bool overwrite = false)
|
||||
{
|
||||
Throw.IfNull(handler);
|
||||
@@ -571,11 +474,9 @@ public class RouteBuilder
|
||||
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
|
||||
/// <param name="handler">A function that processes messages wrapped as <see cref="PortableValue"/> within the
|
||||
/// workflow context and returns a <see cref="ValueTask{TResult}"/> representing the asynchronous result.</param>
|
||||
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
|
||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
||||
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
|
||||
/// preserve existing handlers.</param>
|
||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
|
||||
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
|
||||
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
|
||||
public RouteBuilder AddCatchAll<TResult>(Func<PortableValue, IWorkflowContext, CancellationToken, TResult> handler, bool overwrite = false)
|
||||
{
|
||||
Throw.IfNull(handler);
|
||||
@@ -597,11 +498,9 @@ public class RouteBuilder
|
||||
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
|
||||
/// <param name="handler">A function that processes messages wrapped as <see cref="PortableValue"/> within the
|
||||
/// workflow context and returns a <see cref="ValueTask{TResult}"/> representing the asynchronous result.</param>
|
||||
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
|
||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
||||
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
|
||||
/// preserve existing handlers.</param>
|
||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
|
||||
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
|
||||
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
|
||||
public RouteBuilder AddCatchAll<TResult>(Func<PortableValue, IWorkflowContext, TResult> handler, bool overwrite = false)
|
||||
{
|
||||
Throw.IfNull(handler);
|
||||
@@ -615,29 +514,5 @@ public class RouteBuilder
|
||||
}
|
||||
}
|
||||
|
||||
private void RegisterPortHandlerRouter()
|
||||
{
|
||||
Dictionary<string, PortHandlerF> portHandlers = this._portHandlers;
|
||||
this.AddHandler<ExternalResponse, ExternalResponse?>(InvokeHandlerAsync);
|
||||
|
||||
ValueTask<ExternalResponse?> InvokeHandlerAsync(ExternalResponse response, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
if (portHandlers.TryGetValue(response.PortInfo.PortId, out PortHandlerF? portHandler))
|
||||
{
|
||||
return portHandler(response, context, cancellationToken);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unknown port {response.PortInfo}");
|
||||
}
|
||||
}
|
||||
|
||||
internal MessageRouter Build()
|
||||
{
|
||||
if (this._portHandlers.Count > 0)
|
||||
{
|
||||
this.RegisterPortHandlerRouter();
|
||||
}
|
||||
|
||||
return new(this._typedHandlers, [.. this._outputTypes.Values], this._catchAll);
|
||||
}
|
||||
internal MessageRouter Build() => new(this._typedHandlers, [.. this._outputTypes.Values], this._catchAll);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -10,168 +8,51 @@ using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Specialized;
|
||||
|
||||
internal record AIAgentHostState(JsonElement? ThreadState, bool? CurrentTurnEmitEvents);
|
||||
|
||||
internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
|
||||
{
|
||||
private readonly bool _emitEvents;
|
||||
private readonly AIAgent _agent;
|
||||
private readonly AIAgentHostOptions _options;
|
||||
|
||||
private AgentThread? _thread;
|
||||
private bool? _currentTurnEmitEvents;
|
||||
|
||||
private AIContentExternalHandler<UserInputRequestContent, UserInputResponseContent>? _userInputHandler;
|
||||
private AIContentExternalHandler<FunctionCallContent, FunctionResultContent>? _functionCallHandler;
|
||||
|
||||
private static readonly ChatProtocolExecutorOptions s_defaultChatProtocolOptions = new()
|
||||
{
|
||||
AutoSendTurnToken = false,
|
||||
StringMessageChatRole = ChatRole.User
|
||||
};
|
||||
|
||||
public AIAgentHostExecutor(AIAgent agent, AIAgentHostOptions options) : base(id: agent.GetDescriptiveId(),
|
||||
s_defaultChatProtocolOptions,
|
||||
declareCrossRunShareable: false) // Explicitly false, because we maintain turn state on the instance
|
||||
public AIAgentHostExecutor(AIAgent agent, bool emitEvents = false) : base(id: agent.GetDescriptiveId())
|
||||
{
|
||||
this._agent = agent;
|
||||
this._options = options;
|
||||
this._emitEvents = emitEvents;
|
||||
}
|
||||
|
||||
private RouteBuilder ConfigureUserInputRoutes(RouteBuilder routeBuilder)
|
||||
{
|
||||
this._userInputHandler = new AIContentExternalHandler<UserInputRequestContent, UserInputResponseContent>(
|
||||
ref routeBuilder,
|
||||
portId: $"{this.Id}_UserInput",
|
||||
intercepted: this._options.InterceptUserInputRequests,
|
||||
handler: this.HandleUserInputResponseAsync);
|
||||
|
||||
this._functionCallHandler = new AIContentExternalHandler<FunctionCallContent, FunctionResultContent>(
|
||||
ref routeBuilder,
|
||||
portId: $"{this.Id}_FunctionCall",
|
||||
intercepted: this._options.InterceptUnterminatedFunctionCalls,
|
||||
handler: this.HandleFunctionResultAsync);
|
||||
|
||||
return routeBuilder;
|
||||
}
|
||||
|
||||
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
|
||||
{
|
||||
routeBuilder = base.ConfigureRoutes(routeBuilder);
|
||||
return this.ConfigureUserInputRoutes(routeBuilder);
|
||||
}
|
||||
|
||||
private ValueTask HandleUserInputResponseAsync(
|
||||
UserInputResponseContent response,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!this._userInputHandler!.MarkRequestAsHandled(response.Id))
|
||||
{
|
||||
throw new InvalidOperationException($"No pending UserInputRequest found with id '{response.Id}'.");
|
||||
}
|
||||
|
||||
List<ChatMessage> implicitTurnMessages = [new ChatMessage(ChatRole.User, [response])];
|
||||
|
||||
// ContinueTurnAsync owns failing to emit a TurnToken if this response does not clear up all remaining outstanding requests.
|
||||
return this.ContinueTurnAsync(implicitTurnMessages, context, this._currentTurnEmitEvents ?? false, cancellationToken);
|
||||
}
|
||||
|
||||
private ValueTask HandleFunctionResultAsync(
|
||||
FunctionResultContent result,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!this._functionCallHandler!.MarkRequestAsHandled(result.CallId))
|
||||
{
|
||||
throw new InvalidOperationException($"No pending FunctionCall found with id '{result.CallId}'.");
|
||||
}
|
||||
|
||||
List<ChatMessage> implicitTurnMessages = [new ChatMessage(ChatRole.Tool, [result])];
|
||||
return this.ContinueTurnAsync(implicitTurnMessages, context, this._currentTurnEmitEvents ?? false, cancellationToken);
|
||||
}
|
||||
|
||||
public bool ShouldEmitStreamingEvents(bool? emitEvents)
|
||||
=> emitEvents ?? this._options.EmitAgentUpdateEvents ?? false;
|
||||
|
||||
private async ValueTask<AgentThread> EnsureThreadAsync(IWorkflowContext context, CancellationToken cancellationToken) =>
|
||||
private async Task<AgentThread> EnsureThreadAsync(IWorkflowContext context, CancellationToken cancellationToken) =>
|
||||
this._thread ??= await this._agent.GetNewThreadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
private const string UserInputRequestStateKey = nameof(_userInputHandler);
|
||||
private const string FunctionCallRequestStateKey = nameof(_functionCallHandler);
|
||||
private const string AIAgentHostStateKey = nameof(AIAgentHostState);
|
||||
|
||||
private const string ThreadStateKey = nameof(_thread);
|
||||
protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
AIAgentHostState state = new(this._thread?.Serialize(), this._currentTurnEmitEvents);
|
||||
Task coreStateTask = context.QueueStateUpdateAsync(AIAgentHostStateKey, state, cancellationToken: cancellationToken).AsTask();
|
||||
Task userInputRequestsTask = this._userInputHandler?.OnCheckpointingAsync(UserInputRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
|
||||
Task functionCallRequestsTask = this._functionCallHandler?.OnCheckpointingAsync(FunctionCallRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
|
||||
Task threadTask = Task.CompletedTask;
|
||||
if (this._thread is not null)
|
||||
{
|
||||
JsonElement threadValue = this._thread.Serialize();
|
||||
threadTask = context.QueueStateUpdateAsync(ThreadStateKey, threadValue, cancellationToken: cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
Task baseTask = base.OnCheckpointingAsync(context, cancellationToken).AsTask();
|
||||
|
||||
await Task.WhenAll(coreStateTask, userInputRequestsTask, functionCallRequestsTask, baseTask).ConfigureAwait(false);
|
||||
await Task.WhenAll(threadTask, baseTask).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Task userInputRestoreTask = this._userInputHandler?.OnCheckpointRestoredAsync(UserInputRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
|
||||
Task functionCallRestoreTask = this._functionCallHandler?.OnCheckpointRestoredAsync(FunctionCallRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
|
||||
|
||||
AIAgentHostState? state = await context.ReadStateAsync<AIAgentHostState>(AIAgentHostStateKey, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (state != null)
|
||||
JsonElement? threadValue = await context.ReadStateAsync<JsonElement?>(ThreadStateKey, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (threadValue.HasValue)
|
||||
{
|
||||
this._thread = state.ThreadState.HasValue
|
||||
? await this._agent.DeserializeThreadAsync(state.ThreadState.Value, cancellationToken: cancellationToken).ConfigureAwait(false)
|
||||
: null;
|
||||
this._currentTurnEmitEvents = state.CurrentTurnEmitEvents;
|
||||
this._thread = await this._agent.DeserializeThreadAsync(threadValue.Value, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await Task.WhenAll(userInputRestoreTask, functionCallRestoreTask).ConfigureAwait(false);
|
||||
await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private bool HasOutstandingRequests => (this._userInputHandler?.HasPendingRequests == true)
|
||||
|| (this._functionCallHandler?.HasPendingRequests == true);
|
||||
|
||||
// While we save this on the instance, we are not cross-run shareable, but as AgentBinding uses the factory pattern this is not an issue
|
||||
private async ValueTask ContinueTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool emitEvents, CancellationToken cancellationToken)
|
||||
protected override async ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
|
||||
{
|
||||
this._currentTurnEmitEvents = emitEvents;
|
||||
if (this._options.ForwardIncomingMessages)
|
||||
if (emitEvents ?? this._emitEvents)
|
||||
{
|
||||
await context.SendMessageAsync(messages, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
IEnumerable<ChatMessage> filteredMessages = this._options.ReassignOtherAgentsAsUsers
|
||||
? messages.Select(m => m.ChatAssistantToUserIfNotFromNamed(this._agent.Name ?? this._agent.Id))
|
||||
: messages;
|
||||
|
||||
AgentResponse response = await this.InvokeAgentAsync(filteredMessages, context, emitEvents, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await context.SendMessageAsync(response.Messages is List<ChatMessage> list ? list : response.Messages.ToList(), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// If we have no outstanding requests, we can yield a turn token back to the workflow.
|
||||
if (!this.HasOutstandingRequests)
|
||||
{
|
||||
await context.SendMessageAsync(new TurnToken(this._currentTurnEmitEvents), cancellationToken).ConfigureAwait(false);
|
||||
this._currentTurnEmitEvents = null; // Possibly not actually necessary, but cleaning this up makes it clearer when debugging
|
||||
}
|
||||
}
|
||||
|
||||
protected override ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
|
||||
=> this.ContinueTurnAsync(messages, context, this.ShouldEmitStreamingEvents(emitEvents), cancellationToken);
|
||||
|
||||
private async ValueTask<AgentResponse> InvokeAgentAsync(IEnumerable<ChatMessage> messages, IWorkflowContext context, bool emitEvents, CancellationToken cancellationToken = default)
|
||||
{
|
||||
#pragma warning disable MEAI001
|
||||
Dictionary<string, UserInputRequestContent> userInputRequests = new();
|
||||
Dictionary<string, FunctionCallContent> functionCalls = new();
|
||||
AgentResponse response;
|
||||
|
||||
if (emitEvents)
|
||||
{
|
||||
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
// Run the agent in streaming mode only when agent run update events are to be emitted.
|
||||
IAsyncEnumerable<AgentResponseUpdate> agentStream = this._agent.RunStreamingAsync(
|
||||
messages,
|
||||
@@ -179,70 +60,28 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
List<AgentResponseUpdate> updates = [];
|
||||
|
||||
await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false))
|
||||
{
|
||||
await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false);
|
||||
ExtractUnservicedRequests(update.Contents);
|
||||
|
||||
// TODO: FunctionCall request handling, and user info request handling.
|
||||
// In some sense: We should just let it be handled as a ChatMessage, though we should consider
|
||||
// providing some mechanisms to help the user complete the request, or route it out of the
|
||||
// workflow.
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
response = updates.ToAgentResponse();
|
||||
await context.SendMessageAsync(updates.ToAgentResponse().Messages, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Otherwise, run the agent in non-streaming mode.
|
||||
response = await this._agent.RunAsync(messages,
|
||||
await this.EnsureThreadAsync(context, cancellationToken).ConfigureAwait(false),
|
||||
cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
ExtractUnservicedRequests(response.Messages.SelectMany(message => message.Contents));
|
||||
AgentResponse response = await this._agent.RunAsync(
|
||||
messages,
|
||||
await this.EnsureThreadAsync(context, cancellationToken).ConfigureAwait(false),
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await context.SendMessageAsync(response.Messages, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (this._options.EmitAgentResponseEvents == true)
|
||||
{
|
||||
await context.AddEventAsync(new AgentResponseEvent(this.Id, response), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (userInputRequests.Count > 0 || functionCalls.Count > 0)
|
||||
{
|
||||
Task userInputTask = this._userInputHandler?.ProcessRequestContentsAsync(userInputRequests, context, cancellationToken) ?? Task.CompletedTask;
|
||||
Task functionCallTask = this._functionCallHandler?.ProcessRequestContentsAsync(functionCalls, context, cancellationToken) ?? Task.CompletedTask;
|
||||
|
||||
await Task.WhenAll(userInputTask, functionCallTask)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return response;
|
||||
|
||||
void ExtractUnservicedRequests(IEnumerable<AIContent> contents)
|
||||
{
|
||||
foreach (AIContent content in contents)
|
||||
{
|
||||
if (content is UserInputRequestContent userInputRequest)
|
||||
{
|
||||
// It is an error to simultaneously have multiple outstanding user input requests with the same ID.
|
||||
userInputRequests.Add(userInputRequest.Id, userInputRequest);
|
||||
}
|
||||
else if (content is UserInputResponseContent userInputResponse)
|
||||
{
|
||||
// If the set of messages somehow already has a corresponding user input response, remove it.
|
||||
_ = userInputRequests.Remove(userInputResponse.Id);
|
||||
}
|
||||
else if (content is FunctionCallContent functionCall)
|
||||
{
|
||||
// For function calls, we emit an event to notify the workflow.
|
||||
//
|
||||
// possibility 1: this will be handled inline by the agent abstraction
|
||||
// possibility 2: this will not be handled inline by the agent abstraction
|
||||
functionCalls.Add(functionCall.CallId, functionCall);
|
||||
}
|
||||
else if (content is FunctionResultContent functionResult)
|
||||
{
|
||||
_ = functionCalls.Remove(functionResult.CallId);
|
||||
}
|
||||
}
|
||||
}
|
||||
#pragma warning restore MEAI001
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Specialized;
|
||||
|
||||
internal sealed class AIContentExternalHandler<TRequestContent, TResponseContent>
|
||||
where TRequestContent : AIContent
|
||||
where TResponseContent : AIContent
|
||||
{
|
||||
private readonly PortBinding? _portBinding;
|
||||
private ConcurrentDictionary<string, TRequestContent> _pendingRequests = new();
|
||||
|
||||
public AIContentExternalHandler(ref RouteBuilder routeBuilder, string portId, bool intercepted, Func<TResponseContent, IWorkflowContext, CancellationToken, ValueTask> handler)
|
||||
{
|
||||
if (intercepted)
|
||||
{
|
||||
this._portBinding = null;
|
||||
routeBuilder = routeBuilder.AddHandler(handler);
|
||||
}
|
||||
else
|
||||
{
|
||||
routeBuilder = routeBuilder.AddPortHandler<TRequestContent, TResponseContent>(portId, handler, out this._portBinding);
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasPendingRequests => !this._pendingRequests.IsEmpty;
|
||||
|
||||
public Task ProcessRequestContentsAsync(Dictionary<string, TRequestContent> requests, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
IEnumerable<Task> requestTasks = from string requestId in requests.Keys
|
||||
select this.ProcessRequestContentAsync(requestId, requests[requestId], context, cancellationToken)
|
||||
.AsTask();
|
||||
|
||||
return Task.WhenAll(requestTasks);
|
||||
}
|
||||
|
||||
public ValueTask ProcessRequestContentAsync(string id, TRequestContent requestContent, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!this._pendingRequests.TryAdd(id, requestContent))
|
||||
{
|
||||
throw new InvalidOperationException($"A pending request with ID '{id}' already exists.");
|
||||
}
|
||||
|
||||
return this.IsIntercepted
|
||||
? context.SendMessageAsync(requestContent, cancellationToken: cancellationToken)
|
||||
: this._portBinding.PostRequestAsync(requestContent, id, cancellationToken);
|
||||
}
|
||||
|
||||
public bool MarkRequestAsHandled(string id)
|
||||
{
|
||||
return this._pendingRequests.TryRemove(id, out _);
|
||||
}
|
||||
|
||||
[MemberNotNullWhen(false, nameof(_portBinding))]
|
||||
private bool IsIntercepted => this._portBinding == null;
|
||||
|
||||
private static string MakeKey(string id) => $"{id}_PendingRequests";
|
||||
|
||||
public async ValueTask OnCheckpointingAsync(string id, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Dictionary<string, TRequestContent> pendingRequestsCopy = new(this._pendingRequests);
|
||||
await context.QueueStateUpdateAsync(MakeKey(id), pendingRequestsCopy, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask OnCheckpointRestoredAsync(string id, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Dictionary<string, TRequestContent>? loadedState =
|
||||
await context.ReadStateAsync<Dictionary<string, TRequestContent>>(MakeKey(id), cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (loadedState != null)
|
||||
{
|
||||
this._pendingRequests = new ConcurrentDictionary<string, TRequestContent>(loadedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Specialized;
|
||||
|
||||
/// <summary>
|
||||
/// Executor that runs the agent and forwards all messages, input and output, to the next executor.
|
||||
/// </summary>
|
||||
internal sealed class AgentRunStreamingExecutor(AIAgent agent, bool includeInputInOutput)
|
||||
: ChatProtocolExecutor(agent.GetDescriptiveId(), DefaultOptions, declareCrossRunShareable: true), IResettableExecutor
|
||||
{
|
||||
private static ChatProtocolExecutorOptions DefaultOptions => new()
|
||||
{
|
||||
StringMessageChatRole = ChatRole.User
|
||||
};
|
||||
|
||||
protected override async ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
|
||||
{
|
||||
List<ChatMessage>? roleChanged = messages.ChangeAssistantToUserForOtherParticipants(agent.Name ?? agent.Id);
|
||||
|
||||
List<AgentResponseUpdate> updates = [];
|
||||
await foreach (var update in agent.RunStreamingAsync(messages, cancellationToken: cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
updates.Add(update);
|
||||
if (emitEvents is true)
|
||||
{
|
||||
await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
roleChanged.ResetUserToAssistantForChangedRoles();
|
||||
|
||||
List<ChatMessage> result = includeInputInOutput ? [.. messages] : [];
|
||||
result.AddRange(updates.ToAgentResponse().Messages);
|
||||
|
||||
await context.SendMessageAsync(result, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public new ValueTask ResetAsync() => base.ResetAsync();
|
||||
}
|
||||
+2
-2
@@ -8,10 +8,10 @@ using Microsoft.Extensions.AI;
|
||||
namespace Microsoft.Agents.AI.Workflows.Specialized;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an executor that aggregates received chat messages that it then releases when
|
||||
/// Provides an executor that batches received chat messages that it then releases when
|
||||
/// receiving a <see cref="TurnToken"/>.
|
||||
/// </summary>
|
||||
internal sealed class AggregateTurnMessagesExecutor(string id) : ChatProtocolExecutor(id, declareCrossRunShareable: true), IResettableExecutor
|
||||
internal sealed class CollectChatMessagesExecutor(string id) : ChatProtocolExecutor(id, declareCrossRunShareable: true), IResettableExecutor
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
protected override ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
|
||||
@@ -112,11 +112,18 @@ internal sealed class RequestInfoExecutor : Executor
|
||||
|
||||
public async ValueTask<ExternalResponse?> HandleAsync(ExternalResponse message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!this.Port.IsResponsePort(message))
|
||||
Throw.IfNull(message);
|
||||
Throw.IfNull(message.Data);
|
||||
|
||||
if (message.PortInfo.PortId != this.Port.Id)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
object data = message.DataAs(this.Port.Response) ??
|
||||
throw new InvalidOperationException(
|
||||
$"Message type {message.Data.TypeId} is not assignable to the response type {this.Port.Response.Name} of input port {this.Port.Id}.");
|
||||
|
||||
if (this._allowWrapped && this._wrappedRequests.TryGetValue(message.RequestId, out ExternalRequest? originalRequest))
|
||||
{
|
||||
await context.SendMessageAsync(originalRequest.RewrapResponse(message), cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
@@ -126,11 +133,6 @@ internal sealed class RequestInfoExecutor : Executor
|
||||
await context.SendMessageAsync(message, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!message.Data.IsType(this.Port.Response, out object? data))
|
||||
{
|
||||
throw this.Port.CreateExceptionForType(message);
|
||||
}
|
||||
|
||||
await context.SendMessageAsync(data, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return message;
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Specialized;
|
||||
|
||||
internal static class RequestPortExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempts to process the incoming <see cref="ExternalResponse"/> as a response to a request sent
|
||||
/// through the specified <see cref="RequestPort"/>. If the response is to a different port, returns
|
||||
/// <see langword="false"/>. If the port matches, but the response data cannot be interpreted as the
|
||||
/// expected response type, throws an <see cref="InvalidOperationException"/>. Otherwise, returns
|
||||
/// <see langword="true"/>.
|
||||
/// </summary>
|
||||
/// <param name="port">The request port through which the original request was sent.</param>
|
||||
/// <param name="response">The candidate response to be processed</param>
|
||||
/// <returns><see langword="true"/> if the response is for the specified port and the data could be
|
||||
/// interpreted as the expected response type; otherwise, <see langword="false"/>.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown if the response is for the specified port,
|
||||
/// but the data could not be interpreted as the expected response type.</exception>
|
||||
public static bool ShouldProcessResponse(this RequestPort port, ExternalResponse response)
|
||||
{
|
||||
Throw.IfNull(response);
|
||||
Throw.IfNull(response.Data);
|
||||
|
||||
if (!port.IsResponsePort(response))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!response.Data.IsType(port.Response))
|
||||
{
|
||||
throw port.CreateExceptionForType(response);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static bool IsResponsePort(this RequestPort port, ExternalResponse response)
|
||||
=> Throw.IfNull(response).PortInfo.PortId == port.Id;
|
||||
|
||||
internal static InvalidOperationException CreateExceptionForType(this RequestPort port, ExternalResponse response)
|
||||
=> new($"Message type {response.Data.TypeId} is not assignable to the response type {port.Response.Name}" +
|
||||
$" of input port {port.Id}.");
|
||||
}
|
||||
@@ -7,7 +7,6 @@ using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
using Microsoft.Agents.AI.Workflows.Execution;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
@@ -189,16 +188,6 @@ public class Workflow
|
||||
await this.TryResetExecutorRegistrationsAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private sealed class NoOpExternalRequestContext : IExternalRequestContext, IExternalRequestSink
|
||||
{
|
||||
public ValueTask PostAsync(ExternalRequest request) => default;
|
||||
|
||||
IExternalRequestSink IExternalRequestContext.RegisterPort(RequestPort port)
|
||||
{
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a <see cref="ProtocolDescriptor"/> defining how to interact with this workflow.
|
||||
/// </summary>
|
||||
@@ -210,8 +199,6 @@ public class Workflow
|
||||
ExecutorBinding startExecutorRegistration = this.ExecutorBindings[this.StartExecutorId];
|
||||
Executor startExecutor = await startExecutorRegistration.CreateInstanceAsync(string.Empty)
|
||||
.ConfigureAwait(false);
|
||||
startExecutor.Configure(new NoOpExternalRequestContext());
|
||||
|
||||
return startExecutor.DescribeProtocol();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ internal static partial class WorkflowsJsonUtilities
|
||||
[JsonSerializable(typeof(TurnToken))]
|
||||
|
||||
// Built-in Executor State Types
|
||||
[JsonSerializable(typeof(AIAgentHostState))]
|
||||
[JsonSerializable(typeof(AIAgentHostExecutor))]
|
||||
|
||||
// Event Types
|
||||
//[JsonSerializable(typeof(WorkflowEvent))]
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Agents.AI.Workflows.Specialized;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
public class AIAgentHostExecutorTests
|
||||
{
|
||||
private const string TestAgentId = nameof(TestAgentId);
|
||||
private const string TestAgentName = nameof(TestAgentName);
|
||||
|
||||
private static readonly string[] s_messageStrings = [
|
||||
"",
|
||||
"Hello world!",
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
|
||||
"Quisque dignissim ante odio, at facilisis orci porta a. Duis mi augue, fringilla eu egestas a, pellentesque sed lacus."
|
||||
];
|
||||
|
||||
private static List<ChatMessage> TestMessages => TestReplayAgent.ToChatMessages(s_messageStrings);
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null)]
|
||||
[InlineData(null, true)]
|
||||
[InlineData(null, false)]
|
||||
[InlineData(true, null)]
|
||||
[InlineData(true, true)]
|
||||
[InlineData(true, false)]
|
||||
[InlineData(false, null)]
|
||||
[InlineData(false, true)]
|
||||
[InlineData(false, false)]
|
||||
public async Task Test_AgentHostExecutor_EmitsStreamingUpdatesIFFConfiguredAsync(bool? executorSetting, bool? turnSetting)
|
||||
{
|
||||
// Arrange
|
||||
TestRunContext testContext = new();
|
||||
TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName);
|
||||
AIAgentHostExecutor executor = new(agent, new() { EmitAgentUpdateEvents = executorSetting });
|
||||
testContext.ConfigureExecutor(executor);
|
||||
|
||||
// Act
|
||||
await executor.TakeTurnAsync(new(turnSetting), testContext.BindWorkflowContext(executor.Id));
|
||||
|
||||
// Assert
|
||||
// The rules are: TurnToken overrides Agent, if set. Default to false, if both unset.
|
||||
bool expectingEvents = turnSetting ?? executorSetting ?? false;
|
||||
|
||||
AgentResponseUpdateEvent[] updates = testContext.Events.OfType<AgentResponseUpdateEvent>().ToArray();
|
||||
if (expectingEvents)
|
||||
{
|
||||
// The way TestReplayAgent is set up, it will emit one update per non-empty AIContent
|
||||
List<AIContent> expectedUpdateContents = TestMessages.SelectMany(message => message.Contents).ToList();
|
||||
|
||||
updates.Should().HaveCount(expectedUpdateContents.Count);
|
||||
for (int i = 0; i < updates.Length; i++)
|
||||
{
|
||||
AgentResponseUpdateEvent updateEvent = updates[i];
|
||||
AIContent expectedUpdateContent = expectedUpdateContents[i];
|
||||
|
||||
updateEvent.ExecutorId.Should().Be(agent.GetDescriptiveId());
|
||||
|
||||
AgentResponseUpdate update = updateEvent.Update;
|
||||
update.AuthorName.Should().Be(TestAgentName);
|
||||
update.AgentId.Should().Be(TestAgentId);
|
||||
update.Contents.Should().HaveCount(1);
|
||||
update.Contents[0].Should().BeEquivalentTo(expectedUpdateContent);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
updates.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task Test_AgentHostExecutor_EmitsResponseIFFConfiguredAsync(bool executorSetting)
|
||||
{
|
||||
// Arrange
|
||||
TestRunContext testContext = new();
|
||||
TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName);
|
||||
AIAgentHostExecutor executor = new(agent, new() { EmitAgentResponseEvents = executorSetting });
|
||||
testContext.ConfigureExecutor(executor);
|
||||
|
||||
// Act
|
||||
await executor.TakeTurnAsync(new(), testContext.BindWorkflowContext(executor.Id));
|
||||
|
||||
// Assert
|
||||
AgentResponseEvent[] updates = testContext.Events.OfType<AgentResponseEvent>().ToArray();
|
||||
if (executorSetting)
|
||||
{
|
||||
updates.Should().HaveCount(1);
|
||||
|
||||
AgentResponseEvent responseEvent = updates[0];
|
||||
responseEvent.ExecutorId.Should().Be(agent.GetDescriptiveId());
|
||||
|
||||
AgentResponse response = responseEvent.Response;
|
||||
response.AgentId.Should().Be(TestAgentId);
|
||||
response.Messages.Should().HaveCount(TestMessages.Count - 1);
|
||||
|
||||
for (int i = 0; i < response.Messages.Count; i++)
|
||||
{
|
||||
ChatMessage responseMessage = response.Messages[i];
|
||||
ChatMessage expectedMessage = TestMessages[i + 1]; // Skip the first empty message
|
||||
|
||||
responseMessage.AuthorName.Should().Be(TestAgentName);
|
||||
responseMessage.Text.Should().Be(expectedMessage.Text);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
updates.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
private static ChatMessage UserMessage => new(ChatRole.User, "Hello from User!") { AuthorName = "User" };
|
||||
private static ChatMessage AssistantMessage => new(ChatRole.Assistant, "Hello from Assistant!") { AuthorName = "User" };
|
||||
private static ChatMessage TestAgentMessage => new(ChatRole.Assistant, $"Hello from {TestAgentName}!") { AuthorName = TestAgentName };
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, true, false, false)]
|
||||
[InlineData(true, true, false, true)]
|
||||
[InlineData(true, true, true, false)]
|
||||
[InlineData(true, true, true, true)]
|
||||
[InlineData(true, false, false, false)]
|
||||
[InlineData(true, false, false, true)]
|
||||
[InlineData(true, false, true, false)]
|
||||
[InlineData(true, false, true, true)]
|
||||
[InlineData(false, true, false, false)]
|
||||
[InlineData(false, true, false, true)]
|
||||
[InlineData(false, true, true, false)]
|
||||
[InlineData(false, true, true, true)]
|
||||
[InlineData(false, false, false, false)]
|
||||
[InlineData(false, false, false, true)]
|
||||
[InlineData(false, false, true, false)]
|
||||
[InlineData(false, false, true, true)]
|
||||
public async Task Test_AgentHostExecutor_ReassignsRolesIFFConfiguredAsync(bool executorSetting, bool includeUser, bool includeSelfMessages, bool includeOtherMessages)
|
||||
{
|
||||
// Arrange
|
||||
TestRunContext testContext = new();
|
||||
RoleCheckAgent agent = new(false, TestAgentId, TestAgentName);
|
||||
AIAgentHostExecutor executor = new(agent, new() { ReassignOtherAgentsAsUsers = executorSetting });
|
||||
testContext.ConfigureExecutor(executor);
|
||||
|
||||
List<ChatMessage> messages = [];
|
||||
|
||||
if (includeUser)
|
||||
{
|
||||
messages.Add(UserMessage);
|
||||
}
|
||||
|
||||
if (includeSelfMessages)
|
||||
{
|
||||
messages.Add(TestAgentMessage);
|
||||
}
|
||||
|
||||
if (includeOtherMessages)
|
||||
{
|
||||
messages.Add(AssistantMessage);
|
||||
}
|
||||
|
||||
// Act
|
||||
await executor.Router.RouteMessageAsync(messages, testContext.BindWorkflowContext(executor.Id));
|
||||
|
||||
Func<Task> act = async () => await executor.TakeTurnAsync(new(), testContext.BindWorkflowContext(executor.Id));
|
||||
|
||||
// Assert
|
||||
bool shouldThrow = includeOtherMessages && !executorSetting;
|
||||
|
||||
if (shouldThrow)
|
||||
{
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
}
|
||||
else
|
||||
{
|
||||
await act.Should().NotThrowAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, TestAgentRequestType.FunctionCall)]
|
||||
[InlineData(false, TestAgentRequestType.FunctionCall)]
|
||||
//[InlineData(true, TestAgentRequestType.UserInputRequest)] TODO: Enable when we support polymorphic routing
|
||||
[InlineData(false, TestAgentRequestType.UserInputRequest)]
|
||||
public async Task Test_AgentHostExecutor_InterceptsRequestsIFFConfiguredAsync(bool intercept, TestAgentRequestType requestType)
|
||||
{
|
||||
const int UnpairedRequestCount = 2;
|
||||
const int PairedRequestCount = 3;
|
||||
|
||||
// Arrange
|
||||
TestRunContext testContext = new();
|
||||
TestRequestAgent agent = new(requestType, UnpairedRequestCount, PairedRequestCount, TestAgentId, TestAgentName);
|
||||
AIAgentHostOptions agentHostOptions = requestType switch
|
||||
{
|
||||
TestAgentRequestType.FunctionCall =>
|
||||
new()
|
||||
{
|
||||
EmitAgentResponseEvents = true,
|
||||
InterceptUnterminatedFunctionCalls = intercept
|
||||
},
|
||||
TestAgentRequestType.UserInputRequest =>
|
||||
new()
|
||||
{
|
||||
EmitAgentResponseEvents = true,
|
||||
InterceptUserInputRequests = intercept
|
||||
},
|
||||
_ => throw new NotSupportedException()
|
||||
};
|
||||
|
||||
AIAgentHostExecutor executor = new(agent, agentHostOptions);
|
||||
testContext.ConfigureExecutor(executor);
|
||||
|
||||
// Act
|
||||
await executor.TakeTurnAsync(new(), testContext.BindWorkflowContext(executor.Id));
|
||||
|
||||
// Assert
|
||||
List<object> responses;
|
||||
if (intercept)
|
||||
{
|
||||
// We expect to have a sent message containing the requests as an ExternalRequest
|
||||
switch (requestType)
|
||||
{
|
||||
case TestAgentRequestType.FunctionCall:
|
||||
responses = ExtractAndValidateRequestContents<FunctionCallContent>();
|
||||
break;
|
||||
case TestAgentRequestType.UserInputRequest:
|
||||
responses = ExtractAndValidateRequestContents<UserInputRequestContent>();
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
List<object> ExtractAndValidateRequestContents<TRequest>() where TRequest : AIContent
|
||||
{
|
||||
IEnumerable<TRequest> requests = testContext.QueuedMessages.Should().ContainKey(executor.Id)
|
||||
.WhoseValue
|
||||
.Select(envelope => envelope.Message as TRequest)
|
||||
.Where(item => item is not null)
|
||||
.Select(item => item!);
|
||||
|
||||
return agent.ValidateUnpairedRequests(requests).ToList();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
responses = agent.ValidateUnpairedRequests([.. testContext.ExternalRequests]).ToList<object>();
|
||||
}
|
||||
|
||||
// Act 2
|
||||
foreach (object response in responses.Take(UnpairedRequestCount - 1))
|
||||
{
|
||||
await executor.Router.RouteMessageAsync(response, testContext.BindWorkflowContext(executor.Id));
|
||||
}
|
||||
|
||||
// Assert 2
|
||||
// Since we are not finished, we expect the agent to not have produced a final response (="Remaining: 1")
|
||||
AgentResponseEvent lastResponseEvent = testContext.Events.OfType<AgentResponseEvent>().Should().NotBeEmpty()
|
||||
.And.Subject.Last();
|
||||
|
||||
lastResponseEvent.Response.Text.Should().Be("Remaining: 1");
|
||||
|
||||
// Act 3
|
||||
object finalResponse = responses.Last();
|
||||
await executor.Router.RouteMessageAsync(finalResponse, testContext.BindWorkflowContext(executor.Id));
|
||||
|
||||
// Assert 3
|
||||
// Now that we are finished, we expect the agent to have produced a final response
|
||||
lastResponseEvent = testContext.Events.OfType<AgentResponseEvent>().Should().NotBeEmpty()
|
||||
.And.Subject.Last();
|
||||
|
||||
lastResponseEvent.Response.Text.Should().Be("Done");
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
internal sealed class DynamicPortsExecutor<TRequest, TResponse>(string id, params IEnumerable<string> ports) : Executor(id)
|
||||
{
|
||||
public Dictionary<string, PortBinding> PortBindings { get; } = new();
|
||||
|
||||
public ConcurrentDictionary<string, ConcurrentQueue<TResponse>> ReceivedResponses { get; } = new();
|
||||
|
||||
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
|
||||
{
|
||||
foreach (string portId in ports)
|
||||
{
|
||||
routeBuilder = routeBuilder
|
||||
.AddPortHandler<TRequest, TResponse>(portId,
|
||||
(response, context, cancellationToken) =>
|
||||
{
|
||||
this.ReceivedResponses.GetOrAdd(portId, _ => new()).Enqueue(response);
|
||||
return default;
|
||||
}, out PortBinding? binding);
|
||||
|
||||
this.PortBindings[portId] = binding;
|
||||
}
|
||||
|
||||
return routeBuilder;
|
||||
}
|
||||
|
||||
public ValueTask PostRequestAsync(string portId, TRequest request, TestRunContext testContext, string? requestId = null)
|
||||
{
|
||||
PortBinding binding = this.PortBindings[portId];
|
||||
return binding.Sink.PostAsync(ExternalRequest.Create(binding.Port, request, requestId));
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
using Microsoft.Agents.AI.Workflows.Execution;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
public class DynamicRequestPortTests
|
||||
{
|
||||
private sealed class RequestPortTestContext
|
||||
{
|
||||
private const string PortId = "Port1";
|
||||
private const string ExecutorId = "Executor1";
|
||||
|
||||
public RequestPortTestContext()
|
||||
{
|
||||
this.Executor = new(ExecutorId, PortId);
|
||||
this.Executor.Configure(this.ExternalRequestContext);
|
||||
}
|
||||
|
||||
public TestRunContext RunContext { get; } = new();
|
||||
public ExternalRequestContext ExternalRequestContext { get; } = new();
|
||||
|
||||
public DynamicPortsExecutor<string, int> Executor { get; }
|
||||
|
||||
public PortBinding PortBinding => this.Executor.PortBindings[PortId];
|
||||
|
||||
public ExternalRequest Request => this.ExternalRequestContext.ExternalRequests[0];
|
||||
|
||||
public static async ValueTask<RequestPortTestContext> CreateAsync(string requestData = "Request", bool validate = true)
|
||||
{
|
||||
RequestPortTestContext result = new();
|
||||
|
||||
await result.Executor.PostRequestAsync(PortId, requestData, result.RunContext);
|
||||
|
||||
if (validate)
|
||||
{
|
||||
result.ExternalRequestContext
|
||||
.ExternalRequests.Should().HaveCount(1)
|
||||
.And.AllSatisfy(request => request.PortInfo.Should().Be(result.PortBinding.Port.ToPortInfo()));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public ValueTask<object?> InvokeExecutorWithResponseAsync(ExternalResponse response)
|
||||
=> this.Executor.ExecuteAsync(response, new(typeof(ExternalResponse)), this.RunContext.BindWorkflowContext(this.Executor.Id));
|
||||
}
|
||||
|
||||
private sealed class ExternalRequestContext : IExternalRequestContext, IExternalRequestSink
|
||||
{
|
||||
public List<ExternalRequest> ExternalRequests { get; } = new();
|
||||
|
||||
public ValueTask PostAsync(ExternalRequest request)
|
||||
{
|
||||
this.ExternalRequests.Add(request);
|
||||
return default;
|
||||
}
|
||||
|
||||
public IExternalRequestSink RegisterPort(RequestPort port)
|
||||
{
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_DynamicRequestPort_DeliversExpectedResponseAsync()
|
||||
{
|
||||
RequestPortTestContext context = await RequestPortTestContext.CreateAsync();
|
||||
|
||||
ExternalRequest request = context.Request;
|
||||
await context.InvokeExecutorWithResponseAsync(request.CreateResponse(13));
|
||||
|
||||
string portId = request.PortInfo.PortId;
|
||||
context.Executor.ReceivedResponses.Should().HaveCount(1)
|
||||
.And.ContainKey(portId);
|
||||
context.Executor.ReceivedResponses[portId].Should().HaveCount(1);
|
||||
context.Executor.ReceivedResponses[portId].First().Should().Be(13);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_DynamicRequestPort_ThrowsOnWrongPortAsync()
|
||||
{
|
||||
RequestPortTestContext context = await RequestPortTestContext.CreateAsync();
|
||||
|
||||
ExternalRequest request = context.Request;
|
||||
ExternalRequest fakeRequest = new(RequestPort.Create<string, int>("port2").ToPortInfo(), request.RequestId, request.Data);
|
||||
|
||||
Func<Task> act = async () => await context.InvokeExecutorWithResponseAsync(fakeRequest.CreateResponse(13));
|
||||
(await act.Should().ThrowAsync<TargetInvocationException>())
|
||||
.WithInnerException<InvalidOperationException>();
|
||||
}
|
||||
}
|
||||
@@ -1,95 +1,24 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
using Microsoft.Agents.AI.Workflows.Execution;
|
||||
using Microsoft.Agents.AI.Workflows.Specialized;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
public class EdgeMapSmokeTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Test_EdgeMap_RoutesStaticPortAsync()
|
||||
{
|
||||
TestRunContext runContext = new();
|
||||
|
||||
RequestPort staticPort = RequestPort.Create<string, int>("port1");
|
||||
RequestInfoExecutor executor = new(staticPort);
|
||||
EdgeMap edgeMap = new(runContext, [], [staticPort], executor.Id, null);
|
||||
|
||||
runContext.ConfigureExecutor(executor, edgeMap);
|
||||
|
||||
ExternalResponse responseMessage = new(staticPort.ToPortInfo(), "Request1", new(12));
|
||||
|
||||
DeliveryMapping? mapping = await edgeMap.PrepareDeliveryForResponseAsync(responseMessage);
|
||||
mapping.Should().NotBeNull();
|
||||
|
||||
List<MessageDelivery> deliveries = mapping.Deliveries.ToList();
|
||||
deliveries.Should().HaveCount(1).And.AllSatisfy(delivery => delivery.TargetId.Should().Be(executor.Id));
|
||||
deliveries[0].Envelope.Message.Should().Be(responseMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_EdgeMap_RoutesDynamicPortAsync()
|
||||
{
|
||||
TestRunContext runContext = new();
|
||||
|
||||
DynamicPortsExecutor<string, int> executor = new("executor1", "port1", "port2");
|
||||
EdgeMap edgeMap = new(runContext, [], [], executor.Id, null);
|
||||
|
||||
runContext.ConfigureExecutor(executor, edgeMap);
|
||||
|
||||
await RunPortTestAsync("port1");
|
||||
await RunPortTestAsync("port2");
|
||||
|
||||
async ValueTask RunPortTestAsync(string portId)
|
||||
{
|
||||
PortBinding binding = executor.PortBindings[portId];
|
||||
ExternalResponse responseMessage = new(binding.Port.ToPortInfo(), $"RequestFor[{portId}]", new(10));
|
||||
|
||||
DeliveryMapping? mapping = await edgeMap.PrepareDeliveryForResponseAsync(responseMessage);
|
||||
mapping.Should().NotBeNull();
|
||||
|
||||
List<MessageDelivery> deliveries = mapping.Deliveries.ToList();
|
||||
deliveries.Should().HaveCount(1).And.AllSatisfy(delivery => delivery.TargetId.Should().Be(executor.Id));
|
||||
deliveries[0].Envelope.Message.Should().Be(responseMessage);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_EdgeMap_DoesNotRouteUnregisteredPortAsync()
|
||||
{
|
||||
TestRunContext runContext = new();
|
||||
|
||||
RequestPort staticPort = RequestPort.Create<string, int>("port1");
|
||||
RequestInfoExecutor staticExecutor = new(staticPort);
|
||||
DynamicPortsExecutor<string, int> executor = new("executor1", "port2", "port3");
|
||||
EdgeMap edgeMap = new(runContext, [], [staticPort], executor.Id, null);
|
||||
|
||||
runContext.ConfigureExecutors([staticExecutor, executor], edgeMap);
|
||||
|
||||
await RunPortTestAsync("port4");
|
||||
|
||||
async ValueTask RunPortTestAsync(string portId)
|
||||
{
|
||||
RequestPort fakePort = RequestPort.Create<string, int>(portId);
|
||||
|
||||
ExternalResponse responseMessage = new(fakePort.ToPortInfo(), $"RequestFor[{portId}]", new(10));
|
||||
|
||||
Func<Task<DeliveryMapping?>> mappingTask = async () => await edgeMap.PrepareDeliveryForResponseAsync(responseMessage);
|
||||
await mappingTask.Should().ThrowAsync<InvalidOperationException>();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_EdgeMap_MaintainsFanInEdgeStateAsync()
|
||||
{
|
||||
TestRunContext runContext = new();
|
||||
|
||||
runContext.Executors["executor1"] = new ForwardMessageExecutor<string>("executor1");
|
||||
runContext.Executors["executor2"] = new ForwardMessageExecutor<string>("executor2");
|
||||
runContext.Executors["executor3"] = new ForwardMessageExecutor<string>("executor3");
|
||||
|
||||
Dictionary<string, HashSet<Edge>> workflowEdges = [];
|
||||
|
||||
FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0), null);
|
||||
@@ -97,14 +26,8 @@ public class EdgeMapSmokeTests
|
||||
|
||||
workflowEdges["executor1"] = [fanInEdge];
|
||||
workflowEdges["executor2"] = [fanInEdge];
|
||||
EdgeMap edgeMap = new(runContext, workflowEdges, [], "executor1", null);
|
||||
|
||||
runContext.ConfigureExecutors(
|
||||
[
|
||||
new ForwardMessageExecutor<string>("executor1"),
|
||||
new ForwardMessageExecutor<string>("executor2"),
|
||||
new ForwardMessageExecutor<string>("executor3")
|
||||
], edgeMap);
|
||||
EdgeMap edgeMap = new(runContext, workflowEdges, [], "executor1", null);
|
||||
|
||||
DeliveryMapping? mapping = await edgeMap.PrepareDeliveryForEdgeAsync(fanInEdge, new("part1", "executor1"));
|
||||
mapping.Should().BeNull();
|
||||
|
||||
@@ -28,11 +28,9 @@ public class EdgeRunnerTests
|
||||
: null;
|
||||
|
||||
TestRunContext runContext = new();
|
||||
runContext.ConfigureExecutors(
|
||||
[
|
||||
new ForwardMessageExecutor<string>("executor1"),
|
||||
new ForwardMessageExecutor<string>("executor2")
|
||||
]);
|
||||
|
||||
runContext.Executors["executor1"] = new ForwardMessageExecutor<string>("executor1");
|
||||
runContext.Executors["executor2"] = new ForwardMessageExecutor<string>("executor2");
|
||||
|
||||
DirectEdgeData edgeData = new("executor1", "executor2", new EdgeId(0), condition);
|
||||
DirectEdgeRunner runner = new(runContext, edgeData);
|
||||
@@ -80,11 +78,9 @@ public class EdgeRunnerTests
|
||||
{
|
||||
TestRunContext runContext = new();
|
||||
|
||||
runContext.ConfigureExecutors([
|
||||
new ForwardMessageExecutor<string>("executor1"),
|
||||
new ForwardMessageExecutor<string>("executor2"),
|
||||
new ForwardMessageExecutor<string>("executor3")
|
||||
]);
|
||||
runContext.Executors["executor1"] = new ForwardMessageExecutor<string>("executor1");
|
||||
runContext.Executors["executor2"] = new ForwardMessageExecutor<string>("executor2");
|
||||
runContext.Executors["executor3"] = new ForwardMessageExecutor<string>("executor3");
|
||||
|
||||
Func<object?, int, IEnumerable<int>>? assigner
|
||||
= assignerSelectsEmpty.HasValue
|
||||
@@ -154,11 +150,10 @@ public class EdgeRunnerTests
|
||||
public async Task Test_FanInEdgeRunnerAsync()
|
||||
{
|
||||
TestRunContext runContext = new();
|
||||
runContext.ConfigureExecutors([
|
||||
new ForwardMessageExecutor<string>("executor1"),
|
||||
new ForwardMessageExecutor<string>("executor2"),
|
||||
new ForwardMessageExecutor<string>("executor3")
|
||||
]);
|
||||
|
||||
runContext.Executors["executor1"] = new ForwardMessageExecutor<string>("executor1");
|
||||
runContext.Executors["executor2"] = new ForwardMessageExecutor<string>("executor2");
|
||||
runContext.Executors["executor3"] = new ForwardMessageExecutor<string>("executor3");
|
||||
|
||||
FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0), null);
|
||||
FanInEdgeRunner runner = new(runContext, edgeData);
|
||||
|
||||
@@ -85,7 +85,7 @@ public class RepresentationTests
|
||||
[Fact]
|
||||
public async Task Test_SpecializedExecutor_InfosAsync()
|
||||
{
|
||||
await RunExecutorBindingInfoMatchTestAsync(new AIAgentHostExecutor(new TestAgent(), new()));
|
||||
await RunExecutorBindingInfoMatchTestAsync(new AIAgentHostExecutor(new TestAgent()));
|
||||
await RunExecutorBindingInfoMatchTestAsync(new RequestInfoExecutor(TestRequestPort));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
internal sealed class RoleCheckAgent(bool allowOtherAssistantRoles, string? id = null, string? name = null) : AIAgent
|
||||
{
|
||||
protected override string? IdCore => id;
|
||||
|
||||
public override string? Name => name;
|
||||
|
||||
public override ValueTask<AgentThread> DeserializeThreadAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> new(new RoleCheckAgentThread());
|
||||
|
||||
public override ValueTask<AgentThread> GetNewThreadAsync(CancellationToken cancellationToken = default) => new(new RoleCheckAgentThread());
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentResponseAsync(cancellationToken);
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (ChatMessage message in messages)
|
||||
{
|
||||
if (!allowOtherAssistantRoles && message.Role == ChatRole.Assistant && !(message.AuthorName == null || message.AuthorName == this.Name))
|
||||
{
|
||||
throw new InvalidOperationException($"Message from other assistant role detected: AuthorName={message.AuthorName}");
|
||||
}
|
||||
}
|
||||
|
||||
yield return new AgentResponseUpdate(ChatRole.Assistant, "Ok")
|
||||
{
|
||||
AgentId = this.Id,
|
||||
AuthorName = this.Name,
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
ResponseId = Guid.NewGuid().ToString("N")
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class RoleCheckAgentThread : InMemoryAgentThread;
|
||||
}
|
||||
+106
-7
@@ -2,6 +2,9 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
@@ -14,6 +17,102 @@ namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
public class SpecializedExecutorSmokeTests
|
||||
{
|
||||
public class TestAIAgent(List<ChatMessage>? messages = null, string? id = null, string? name = null) : AIAgent
|
||||
{
|
||||
protected override string? IdCore => id;
|
||||
public override string? Name => name;
|
||||
|
||||
public static List<ChatMessage> ToChatMessages(params string[] messages)
|
||||
{
|
||||
List<ChatMessage> result = messages.Select(ToMessage).ToList();
|
||||
|
||||
static ChatMessage ToMessage(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return new ChatMessage(ChatRole.Assistant, "") { MessageId = "" };
|
||||
}
|
||||
|
||||
string[] splits = text.Split(' ');
|
||||
for (int i = 0; i < splits.Length - 1; i++)
|
||||
{
|
||||
splits[i] += ' ';
|
||||
}
|
||||
|
||||
List<AIContent> contents = splits.Select<string, AIContent>(text => new TextContent(text) { RawRepresentation = text }).ToList();
|
||||
return new(ChatRole.Assistant, contents)
|
||||
{
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
RawRepresentation = text,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public override ValueTask<AgentThread> GetNewThreadAsync(CancellationToken cancellationToken = default)
|
||||
=> new(new TestAgentThread());
|
||||
|
||||
public override ValueTask<AgentThread> DeserializeThreadAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> new(new TestAgentThread());
|
||||
|
||||
public static TestAIAgent FromStrings(params string[] messages) =>
|
||||
new(ToChatMessages(messages));
|
||||
|
||||
public List<ChatMessage> Messages { get; } = Validate(messages) ?? [];
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new AgentResponse(this.Messages)
|
||||
{
|
||||
AgentId = this.Id,
|
||||
ResponseId = Guid.NewGuid().ToString("N")
|
||||
});
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
string responseId = Guid.NewGuid().ToString("N");
|
||||
foreach (ChatMessage message in this.Messages)
|
||||
{
|
||||
foreach (AIContent content in message.Contents)
|
||||
{
|
||||
yield return new AgentResponseUpdate()
|
||||
{
|
||||
AgentId = this.Id,
|
||||
MessageId = message.MessageId,
|
||||
ResponseId = responseId,
|
||||
Contents = [content],
|
||||
Role = message.Role,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<ChatMessage>? Validate(List<ChatMessage>? candidateMessages)
|
||||
{
|
||||
string? currentMessageId = null;
|
||||
|
||||
if (candidateMessages is not null)
|
||||
{
|
||||
foreach (ChatMessage message in candidateMessages)
|
||||
{
|
||||
if (currentMessageId is null)
|
||||
{
|
||||
currentMessageId = message.MessageId;
|
||||
}
|
||||
else if (currentMessageId == message.MessageId)
|
||||
{
|
||||
throw new ArgumentException("Duplicate consecutive message ids");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidateMessages;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TestAgentThread() : InMemoryAgentThread();
|
||||
|
||||
internal sealed class TestWorkflowContext(string executorId, bool concurrentRunsEnabled = false) : IWorkflowContext
|
||||
{
|
||||
private readonly StateManager _stateManager = new();
|
||||
@@ -78,10 +177,10 @@ public class SpecializedExecutorSmokeTests
|
||||
"Quisque dignissim ante odio, at facilisis orci porta a. Duis mi augue, fringilla eu egestas a, pellentesque sed lacus."
|
||||
];
|
||||
|
||||
List<ChatMessage> expected = TestReplayAgent.ToChatMessages(MessageStrings);
|
||||
List<ChatMessage> expected = TestAIAgent.ToChatMessages(MessageStrings);
|
||||
|
||||
TestReplayAgent agent = new(expected);
|
||||
AIAgentHostExecutor host = new(agent, new());
|
||||
TestAIAgent agent = new(expected);
|
||||
AIAgentHostExecutor host = new(agent);
|
||||
|
||||
TestWorkflowContext collectingContext = new(host.Id);
|
||||
|
||||
@@ -104,8 +203,8 @@ public class SpecializedExecutorSmokeTests
|
||||
{
|
||||
const string AgentAName = "TestAgentAName";
|
||||
const string AgentBName = "TestAgentBName";
|
||||
TestReplayAgent agentA = new(name: AgentAName);
|
||||
TestReplayAgent agentB = new(name: AgentBName);
|
||||
TestAIAgent agentA = new(name: AgentAName);
|
||||
TestAIAgent agentB = new(name: AgentBName);
|
||||
var workflow = new WorkflowBuilder(agentA).AddEdge(agentA, agentB).Build();
|
||||
var definition = workflow.ToWorkflowInfo();
|
||||
|
||||
@@ -126,8 +225,8 @@ public class SpecializedExecutorSmokeTests
|
||||
[Fact]
|
||||
public async Task Test_AIAgent_ExecutorId_Use_Agent_ID_When_Name_Not_ProvidedAsync()
|
||||
{
|
||||
TestReplayAgent agentA = new();
|
||||
TestReplayAgent agentB = new();
|
||||
TestAIAgent agentA = new();
|
||||
TestAIAgent agentB = new();
|
||||
var workflow = new WorkflowBuilder(agentA).AddEdge(agentA, agentB).Build();
|
||||
var definition = workflow.ToWorkflowInfo();
|
||||
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
public class TestReplayAgent(List<ChatMessage>? messages = null, string? id = null, string? name = null) : AIAgent
|
||||
{
|
||||
protected override string? IdCore => id;
|
||||
public override string? Name => name;
|
||||
|
||||
public static List<ChatMessage> ToChatMessages(params string[] messages)
|
||||
{
|
||||
List<ChatMessage> result = messages.Select(ToMessage).ToList();
|
||||
|
||||
static ChatMessage ToMessage(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return new ChatMessage(ChatRole.Assistant, "") { MessageId = "" };
|
||||
}
|
||||
|
||||
string[] splits = text.Split(' ');
|
||||
for (int i = 0; i < splits.Length - 1; i++)
|
||||
{
|
||||
splits[i] += ' ';
|
||||
}
|
||||
|
||||
List<AIContent> contents = splits.Select<string, AIContent>(text => new TextContent(text) { RawRepresentation = text }).ToList();
|
||||
return new(ChatRole.Assistant, contents)
|
||||
{
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
RawRepresentation = text,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public override ValueTask<AgentThread> GetNewThreadAsync(CancellationToken cancellationToken = default)
|
||||
=> new(new ReplayAgentThread());
|
||||
|
||||
public override ValueTask<AgentThread> DeserializeThreadAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> new(new ReplayAgentThread());
|
||||
|
||||
public static TestReplayAgent FromStrings(params string[] messages) =>
|
||||
new(ToChatMessages(messages));
|
||||
|
||||
public List<ChatMessage> Messages { get; } = Validate(messages) ?? [];
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentResponseAsync(cancellationToken);
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
string responseId = Guid.NewGuid().ToString("N");
|
||||
foreach (ChatMessage message in this.Messages)
|
||||
{
|
||||
foreach (AIContent content in message.Contents)
|
||||
{
|
||||
yield return new AgentResponseUpdate()
|
||||
{
|
||||
AgentId = this.Id,
|
||||
AuthorName = this.Name,
|
||||
MessageId = message.MessageId,
|
||||
ResponseId = responseId,
|
||||
Contents = [content],
|
||||
Role = message.Role,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<ChatMessage>? Validate(List<ChatMessage>? candidateMessages)
|
||||
{
|
||||
string? currentMessageId = null;
|
||||
|
||||
if (candidateMessages is not null)
|
||||
{
|
||||
foreach (ChatMessage message in candidateMessages)
|
||||
{
|
||||
if (currentMessageId is null)
|
||||
{
|
||||
currentMessageId = message.MessageId;
|
||||
}
|
||||
else if (currentMessageId == message.MessageId)
|
||||
{
|
||||
throw new ArgumentException("Duplicate consecutive message ids");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidateMessages;
|
||||
}
|
||||
|
||||
private sealed class ReplayAgentThread() : InMemoryAgentThread();
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
internal sealed record TestRequestAgentThreadState(JsonElement ThreadState, Dictionary<string, PortableValue> UnservicedRequests, HashSet<string> ServicedRequests, HashSet<string> PairedRequests);
|
||||
|
||||
public enum TestAgentRequestType
|
||||
{
|
||||
FunctionCall,
|
||||
UserInputRequest
|
||||
}
|
||||
|
||||
internal sealed class TestRequestAgent(TestAgentRequestType requestType, int unpairedRequestCount, int pairedRequestCount, string? id, string? name) : AIAgent
|
||||
{
|
||||
public Random RNG { get; set; } = new Random(HashCode.Combine(requestType, nameof(TestRequestAgent)));
|
||||
|
||||
public AgentThread? LastThread { get; set; }
|
||||
|
||||
protected override string? IdCore => id;
|
||||
public override string? Name => name;
|
||||
|
||||
public override ValueTask<AgentThread> GetNewThreadAsync(CancellationToken cancellationToken)
|
||||
=> new(requestType switch
|
||||
{
|
||||
TestAgentRequestType.FunctionCall => new TestRequestAgentThread<FunctionCallContent, FunctionResultContent>(),
|
||||
TestAgentRequestType.UserInputRequest => new TestRequestAgentThread<UserInputRequestContent, UserInputResponseContent>(),
|
||||
_ => throw new NotSupportedException(),
|
||||
});
|
||||
|
||||
public override ValueTask<AgentThread> DeserializeThreadAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> new(requestType switch
|
||||
{
|
||||
TestAgentRequestType.FunctionCall => new TestRequestAgentThread<FunctionCallContent, FunctionResultContent>(),
|
||||
TestAgentRequestType.UserInputRequest => new TestRequestAgentThread<UserInputRequestContent, UserInputResponseContent>(),
|
||||
_ => throw new NotSupportedException(),
|
||||
});
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentResponseAsync(cancellationToken);
|
||||
|
||||
private static int[] SampleIndicies(Random rng, int n, int c)
|
||||
{
|
||||
int[] result = Enumerable.Range(0, c).ToArray();
|
||||
|
||||
for (int i = c; i < n; i++)
|
||||
{
|
||||
int radix = rng.Next(i);
|
||||
if (radix < c)
|
||||
{
|
||||
result[radix] = i;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async IAsyncEnumerable<AgentResponseUpdate> RunStreamingAsync<TRequest, TResponse>(
|
||||
IRequestResponseStrategy<TRequest, TResponse> strategy,
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentThread? thread = null,
|
||||
AgentRunOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
where TRequest : AIContent
|
||||
where TResponse : AIContent
|
||||
{
|
||||
this.LastThread = thread ??= await this.GetNewThreadAsync(cancellationToken);
|
||||
TestRequestAgentThread<TRequest, TResponse> traThread = ConvertThread<TRequest, TResponse>(thread);
|
||||
|
||||
if (traThread.HasSentRequests)
|
||||
{
|
||||
foreach (TResponse response in messages.SelectMany(message => message.Contents).OfType<TResponse>())
|
||||
{
|
||||
strategy.ProcessResponse(response, traThread);
|
||||
}
|
||||
|
||||
if (traThread.UnservicedRequests.Count == 0)
|
||||
{
|
||||
yield return new(ChatRole.Assistant, "Done");
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return new(ChatRole.Assistant, $"Remaining: {traThread.UnservicedRequests.Count}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
int totalRequestCount = unpairedRequestCount + pairedRequestCount;
|
||||
yield return new(ChatRole.Assistant, $"Creating {totalRequestCount} requests, {pairedRequestCount} paired.");
|
||||
|
||||
HashSet<int> servicedIndicies = [.. SampleIndicies(this.RNG, totalRequestCount, pairedRequestCount)];
|
||||
|
||||
(string, TRequest)[] requests = strategy.CreateRequests(unpairedRequestCount + pairedRequestCount).ToArray();
|
||||
List<AIContent> pairedResponses = new(capacity: pairedRequestCount);
|
||||
|
||||
for (int i = 0; i < requests.Length; i++)
|
||||
{
|
||||
(string id, TRequest request) = requests[i];
|
||||
if (servicedIndicies.Contains(i))
|
||||
{
|
||||
traThread.PairedRequests.Add(id);
|
||||
pairedResponses.Add(strategy.CreatePairedResponse(request));
|
||||
}
|
||||
else
|
||||
{
|
||||
traThread.UnservicedRequests.Add(id, request);
|
||||
}
|
||||
|
||||
yield return new(ChatRole.Assistant, [request]);
|
||||
}
|
||||
|
||||
yield return new(ChatRole.Assistant, pairedResponses);
|
||||
|
||||
traThread.HasSentRequests = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static TestRequestAgentThread<TRequest, TResponse> ConvertThread<TRequest, TResponse>(AgentThread thread)
|
||||
where TRequest : AIContent
|
||||
where TResponse : AIContent
|
||||
{
|
||||
if (thread is not TestRequestAgentThread<TRequest, TResponse> traThread)
|
||||
{
|
||||
throw new ArgumentException($"Bad AgentThread type: Expected {typeof(TestRequestAgentThread<TRequest, TResponse>)}, got {thread.GetType()}.", nameof(thread));
|
||||
}
|
||||
|
||||
return traThread;
|
||||
}
|
||||
|
||||
private sealed class FunctionCallStrategy : IRequestResponseStrategy<FunctionCallContent, FunctionResultContent>
|
||||
{
|
||||
public FunctionResultContent CreatePairedResponse(FunctionCallContent request)
|
||||
{
|
||||
return new FunctionResultContent(request.CallId, request);
|
||||
}
|
||||
|
||||
public IEnumerable<(string, FunctionCallContent)> CreateRequests(int count)
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
string callId = Guid.NewGuid().ToString("N");
|
||||
FunctionCallContent request = new(callId, "TestFunction");
|
||||
yield return (callId, request);
|
||||
}
|
||||
}
|
||||
|
||||
public void ProcessResponse(FunctionResultContent response, TestRequestAgentThread<FunctionCallContent, FunctionResultContent> thread)
|
||||
{
|
||||
if (thread.UnservicedRequests.TryGetValue(response.CallId, out FunctionCallContent? request))
|
||||
{
|
||||
response.Result.As<FunctionCallContent>().Should().Be(request);
|
||||
thread.ServicedRequests.Add(response.CallId);
|
||||
thread.UnservicedRequests.Remove(response.CallId);
|
||||
}
|
||||
else if (thread.ServicedRequests.Contains(response.CallId))
|
||||
{
|
||||
throw new InvalidOperationException($"Seeing duplicate response with id {response.CallId}");
|
||||
}
|
||||
else if (thread.PairedRequests.Contains(response.CallId))
|
||||
{
|
||||
throw new InvalidOperationException($"Seeing explicit response to initially paired request with id {response.CallId}");
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException($"Seeing response to nonexistent request with id {response.CallId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FunctionApprovalStrategy : IRequestResponseStrategy<UserInputRequestContent, UserInputResponseContent>
|
||||
{
|
||||
public UserInputResponseContent CreatePairedResponse(UserInputRequestContent request)
|
||||
{
|
||||
if (request is not FunctionApprovalRequestContent approvalRequest)
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid request: Expecting {typeof(FunctionApprovalResponseContent)}, got {request.GetType()}");
|
||||
}
|
||||
|
||||
return new FunctionApprovalResponseContent(approvalRequest.Id, true, approvalRequest.FunctionCall);
|
||||
}
|
||||
|
||||
public IEnumerable<(string, UserInputRequestContent)> CreateRequests(int count)
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
string id = Guid.NewGuid().ToString("N");
|
||||
UserInputRequestContent request = new FunctionApprovalRequestContent(id, new(id, "TestFunction"));
|
||||
yield return (id, request);
|
||||
}
|
||||
}
|
||||
|
||||
public void ProcessResponse(UserInputResponseContent response, TestRequestAgentThread<UserInputRequestContent, UserInputResponseContent> thread)
|
||||
{
|
||||
if (thread.UnservicedRequests.TryGetValue(response.Id, out UserInputRequestContent? request))
|
||||
{
|
||||
if (request is not FunctionApprovalRequestContent approvalRequest)
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid request: Expecting {typeof(FunctionApprovalResponseContent)}, got {request.GetType()}");
|
||||
}
|
||||
|
||||
if (response is not FunctionApprovalResponseContent approvalResponse)
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid response: Expecting {typeof(FunctionApprovalResponseContent)}, got {response.GetType()}");
|
||||
}
|
||||
|
||||
approvalResponse.Approved.Should().BeTrue();
|
||||
approvalResponse.FunctionCall.As<FunctionCallContent>().Should().Be(approvalRequest.FunctionCall);
|
||||
thread.ServicedRequests.Add(response.Id);
|
||||
thread.UnservicedRequests.Remove(response.Id);
|
||||
}
|
||||
else if (thread.ServicedRequests.Contains(response.Id))
|
||||
{
|
||||
throw new InvalidOperationException($"Seeing duplicate response with id {response.Id}");
|
||||
}
|
||||
else if (thread.PairedRequests.Contains(response.Id))
|
||||
{
|
||||
throw new InvalidOperationException($"Seeing explicit response to initially paired request with id {response.Id}");
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException($"Seeing response to nonexistent request with id {response.Id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private interface IRequestResponseStrategy<TRequest, TResponse>
|
||||
where TRequest : AIContent
|
||||
where TResponse : AIContent
|
||||
{
|
||||
IEnumerable<(string, TRequest)> CreateRequests(int count);
|
||||
TResponse CreatePairedResponse(TRequest request);
|
||||
|
||||
void ProcessResponse(TResponse response, TestRequestAgentThread<TRequest, TResponse> thread);
|
||||
}
|
||||
|
||||
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return requestType switch
|
||||
{
|
||||
TestAgentRequestType.FunctionCall => this.RunStreamingAsync(new FunctionCallStrategy(), messages, thread, options, cancellationToken),
|
||||
TestAgentRequestType.UserInputRequest => this.RunStreamingAsync(new FunctionApprovalStrategy(), messages, thread, options, cancellationToken),
|
||||
_ => throw new NotSupportedException($"Unknown AgentRequestType {requestType}"),
|
||||
};
|
||||
}
|
||||
|
||||
private static string RetrieveId<TRequest>(TRequest request)
|
||||
where TRequest : AIContent
|
||||
{
|
||||
return request switch
|
||||
{
|
||||
FunctionCallContent functionCall => functionCall.CallId,
|
||||
UserInputRequestContent userInputRequest => userInputRequest.Id,
|
||||
_ => throw new NotSupportedException($"Unknown request type {typeof(TRequest)}"),
|
||||
};
|
||||
}
|
||||
|
||||
private IEnumerable<TResponse> ValidateUnpairedRequests<TRequest, TResponse>(IEnumerable<TRequest> requests, IRequestResponseStrategy<TRequest, TResponse> strategy)
|
||||
where TRequest : AIContent
|
||||
where TResponse : AIContent
|
||||
{
|
||||
this.LastThread.Should().NotBeNull();
|
||||
TestRequestAgentThread<TRequest, TResponse> traThread = ConvertThread<TRequest, TResponse>(this.LastThread);
|
||||
|
||||
requests.Should().HaveCount(traThread.UnservicedRequests.Count);
|
||||
foreach (TRequest request in requests)
|
||||
{
|
||||
string requestId = RetrieveId(request);
|
||||
traThread.UnservicedRequests.Should().ContainKey(requestId);
|
||||
yield return strategy.CreatePairedResponse(request);
|
||||
}
|
||||
}
|
||||
|
||||
internal IEnumerable<object> ValidateUnpairedRequests<TRequest>(IEnumerable<TRequest> requests)
|
||||
where TRequest : AIContent
|
||||
{
|
||||
switch (requestType)
|
||||
{
|
||||
case TestAgentRequestType.FunctionCall:
|
||||
if (typeof(TRequest) != typeof(FunctionCallContent))
|
||||
{
|
||||
throw new ArgumentException($"Invalid request type: Expected {typeof(FunctionCallContent)}, got {typeof(TRequest)}", nameof(requests));
|
||||
}
|
||||
|
||||
return this.ValidateUnpairedRequests((IEnumerable<FunctionCallContent>)requests, new FunctionCallStrategy());
|
||||
case TestAgentRequestType.UserInputRequest:
|
||||
if (!typeof(UserInputRequestContent).IsAssignableFrom(typeof(TRequest)))
|
||||
{
|
||||
throw new ArgumentException($"Invalid request type: Expected {typeof(UserInputRequestContent)}, got {typeof(TRequest)}", nameof(requests));
|
||||
}
|
||||
|
||||
return this.ValidateUnpairedRequests((IEnumerable<UserInputRequestContent>)requests, new FunctionApprovalStrategy());
|
||||
default:
|
||||
throw new NotSupportedException($"Unknown AgentRequestType {requestType}");
|
||||
}
|
||||
}
|
||||
|
||||
internal IEnumerable<ExternalResponse> ValidateUnpairedRequests(List<ExternalRequest> requests)
|
||||
{
|
||||
List<object> responses;
|
||||
switch (requestType)
|
||||
{
|
||||
case TestAgentRequestType.FunctionCall:
|
||||
responses = this.ValidateUnpairedRequests(requests.Select(AssertAndExtractRequestContent<FunctionCallContent>)).ToList();
|
||||
break;
|
||||
case TestAgentRequestType.UserInputRequest:
|
||||
responses = this.ValidateUnpairedRequests(requests.Select(AssertAndExtractRequestContent<UserInputRequestContent>)).ToList();
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException($"Unknown AgentRequestType {requestType}");
|
||||
}
|
||||
|
||||
return Enumerable.Zip(requests, responses, (ExternalRequest request, object response) => request.CreateResponse(response));
|
||||
|
||||
static TRequest AssertAndExtractRequestContent<TRequest>(ExternalRequest request)
|
||||
{
|
||||
request.DataIs(out TRequest? content).Should().BeTrue();
|
||||
return content!;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestRequestAgentThread<TRequest, TResponse> : InMemoryAgentThread
|
||||
where TRequest : AIContent
|
||||
where TResponse : AIContent
|
||||
{
|
||||
public TestRequestAgentThread()
|
||||
{
|
||||
}
|
||||
|
||||
public bool HasSentRequests { get; set; }
|
||||
public Dictionary<string, TRequest> UnservicedRequests { get; } = new();
|
||||
public HashSet<string> ServicedRequests { get; } = new();
|
||||
public HashSet<string> PairedRequests { get; } = new();
|
||||
|
||||
private static JsonElement DeserializeAndExtractState(JsonElement serializedState,
|
||||
out TestRequestAgentThreadState state,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
state = JsonSerializer.Deserialize<TestRequestAgentThreadState>(serializedState, jsonSerializerOptions)
|
||||
?? throw new ArgumentException("Unable to deserialize thread state.");
|
||||
|
||||
return state.ThreadState;
|
||||
}
|
||||
|
||||
public TestRequestAgentThread(JsonElement element, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
: base(DeserializeAndExtractState(element, out TestRequestAgentThreadState state, jsonSerializerOptions))
|
||||
{
|
||||
this.UnservicedRequests = state.UnservicedRequests.ToDictionary(
|
||||
keySelector: item => item.Key,
|
||||
elementSelector: item => item.Value.As<TRequest>()!);
|
||||
|
||||
this.ServicedRequests = state.ServicedRequests;
|
||||
this.PairedRequests = state.PairedRequests;
|
||||
}
|
||||
|
||||
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
JsonElement threadState = base.Serialize(jsonSerializerOptions);
|
||||
|
||||
Dictionary<string, PortableValue> portableUnservicedRequests =
|
||||
this.UnservicedRequests.ToDictionary(
|
||||
keySelector: item => item.Key,
|
||||
elementSelector: item => new PortableValue(item.Value));
|
||||
|
||||
TestRequestAgentThreadState state = new(threadState, portableUnservicedRequests, this.ServicedRequests, this.PairedRequests);
|
||||
|
||||
return JsonSerializer.SerializeToElement(state, jsonSerializerOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -11,36 +10,6 @@ namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
public class TestRunContext : IRunnerContext
|
||||
{
|
||||
private sealed class TestExternalRequestContext(IRunnerContext runnerContext, string executorId, EdgeMap? map) : IExternalRequestContext
|
||||
{
|
||||
public IExternalRequestSink RegisterPort(RequestPort port)
|
||||
{
|
||||
if (map?.TryRegisterPort(runnerContext, executorId, port) == false)
|
||||
{
|
||||
throw new InvalidOperationException("Duplicate port id: " + port.Id);
|
||||
}
|
||||
|
||||
return runnerContext;
|
||||
}
|
||||
}
|
||||
|
||||
internal TestRunContext ConfigureExecutor(Executor executor, EdgeMap? map = null)
|
||||
{
|
||||
executor.Configure(new TestExternalRequestContext(this, executor.Id, map));
|
||||
this.Executors.Add(executor.Id, executor);
|
||||
return this;
|
||||
}
|
||||
|
||||
internal TestRunContext ConfigureExecutors(IEnumerable<Executor> executors, EdgeMap? map = null)
|
||||
{
|
||||
foreach (var executor in executors)
|
||||
{
|
||||
this.ConfigureExecutor(executor, map);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
private sealed class BoundContext(
|
||||
string executorId,
|
||||
TestRunContext runnerContext,
|
||||
@@ -88,13 +57,13 @@ public class TestRunContext : IRunnerContext
|
||||
return default;
|
||||
}
|
||||
|
||||
public IWorkflowContext BindWorkflowContext(string executorId, Dictionary<string, string>? traceContext = null)
|
||||
public IWorkflowContext Bind(string executorId, Dictionary<string, string>? traceContext = null)
|
||||
=> new BoundContext(executorId, this, traceContext);
|
||||
|
||||
public ConcurrentQueue<ExternalRequest> ExternalRequests { get; } = [];
|
||||
public List<ExternalRequest> ExternalRequests { get; } = [];
|
||||
public ValueTask PostAsync(ExternalRequest request)
|
||||
{
|
||||
this.ExternalRequests.Enqueue(request);
|
||||
this.ExternalRequests.Add(request);
|
||||
return default;
|
||||
}
|
||||
|
||||
@@ -130,8 +99,8 @@ public class TestRunContext : IRunnerContext
|
||||
public Dictionary<string, Executor> Executors { get; set; } = [];
|
||||
public string StartingExecutorId { get; set; } = string.Empty;
|
||||
|
||||
public bool WithCheckpointing => false;
|
||||
public bool ConcurrentRunsEnabled => false;
|
||||
public bool WithCheckpointing => throw new NotSupportedException();
|
||||
public bool ConcurrentRunsEnabled => throw new NotSupportedException();
|
||||
|
||||
ValueTask<Executor> IRunnerContext.EnsureExecutorAsync(string executorId, IStepTracer? tracer, CancellationToken cancellationToken) =>
|
||||
new(this.Executors[executorId]);
|
||||
|
||||
+1
-33
@@ -7,37 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.0b260123] - 2026-01-23
|
||||
|
||||
### Added
|
||||
|
||||
- **agent-framework-azure-ai**: Add support for `rai_config` in agent creation ([#3265](https://github.com/microsoft/agent-framework/pull/3265))
|
||||
- **agent-framework-azure-ai**: Support reasoning config for `AzureAIClient` ([#3403](https://github.com/microsoft/agent-framework/pull/3403))
|
||||
- **agent-framework-anthropic**: Add `response_format` support for structured outputs ([#3301](https://github.com/microsoft/agent-framework/pull/3301))
|
||||
|
||||
### Changed
|
||||
|
||||
- **agent-framework-core**: [BREAKING] Simplify content types to a single class with classmethod constructors ([#3252](https://github.com/microsoft/agent-framework/pull/3252))
|
||||
- **agent-framework-core**: [BREAKING] Make `response_format` validation errors visible to users ([#3274](https://github.com/microsoft/agent-framework/pull/3274))
|
||||
- **agent-framework-ag-ui**: [BREAKING] Simplify run logic; fix MCP and Anthropic client issues ([#3322](https://github.com/microsoft/agent-framework/pull/3322))
|
||||
- **agent-framework-core**: Prefer runtime `kwargs` for `conversation_id` in OpenAI Responses client ([#3312](https://github.com/microsoft/agent-framework/pull/3312))
|
||||
|
||||
### Fixed
|
||||
|
||||
- **agent-framework-core**: Verify types during checkpoint deserialization to prevent marker spoofing ([#3243](https://github.com/microsoft/agent-framework/pull/3243))
|
||||
- **agent-framework-core**: Filter internal args when passing kwargs to MCP tools ([#3292](https://github.com/microsoft/agent-framework/pull/3292))
|
||||
- **agent-framework-core**: Handle anyio cancel scope errors during MCP connection cleanup ([#3277](https://github.com/microsoft/agent-framework/pull/3277))
|
||||
- **agent-framework-core**: Filter `conversation_id` when passing kwargs to agent as tool ([#3266](https://github.com/microsoft/agent-framework/pull/3266))
|
||||
- **agent-framework-core**: Fix `use_agent_middleware` calling private `_normalize_messages` ([#3264](https://github.com/microsoft/agent-framework/pull/3264))
|
||||
- **agent-framework-core**: Add `system_instructions` to ChatClient LLM span tracing ([#3164](https://github.com/microsoft/agent-framework/pull/3164))
|
||||
- **agent-framework-core**: Fix Azure chat client asynchronous filtering ([#3260](https://github.com/microsoft/agent-framework/pull/3260))
|
||||
- **agent-framework-core**: Fix `HostedImageGenerationTool` mapping to `ImageGenTool` for Azure AI ([#3263](https://github.com/microsoft/agent-framework/pull/3263))
|
||||
- **agent-framework-azure-ai**: Fix local MCP tools with `AzureAIProjectAgentProvider` ([#3315](https://github.com/microsoft/agent-framework/pull/3315))
|
||||
- **agent-framework-azurefunctions**: Fix MCP tool invocation to use the correct agent ([#3339](https://github.com/microsoft/agent-framework/pull/3339))
|
||||
- **agent-framework-declarative**: Fix MCP tool connection not passed from YAML to Azure AI agent creation API ([#3248](https://github.com/microsoft/agent-framework/pull/3248))
|
||||
- **agent-framework-ag-ui**: Properly handle JSON serialization with handoff workflows as agent ([#3275](https://github.com/microsoft/agent-framework/pull/3275))
|
||||
- **agent-framework-devui**: Ensure proper form rendering for `int` ([#3201](https://github.com/microsoft/agent-framework/pull/3201))
|
||||
|
||||
## [1.0.0b260116] - 2026-01-16
|
||||
|
||||
### Added
|
||||
@@ -542,8 +511,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
|
||||
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260123...HEAD
|
||||
[1.0.0b260123]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260116...python-1.0.0b260123
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260116...HEAD
|
||||
[1.0.0b260116]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260114...python-1.0.0b260116
|
||||
[1.0.0b260114]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260107...python-1.0.0b260114
|
||||
[1.0.0b260107]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260106...python-1.0.0b260107
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260123"
|
||||
version = "1.0.0b260116"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "agent-framework-ag-ui"
|
||||
version = "1.0.0b260123"
|
||||
version = "1.0.0b260116"
|
||||
description = "AG-UI protocol integration for Agent Framework"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260123"
|
||||
version = "1.0.0b260116"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260123"
|
||||
version = "1.0.0b260116"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure AI Foundry integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260123"
|
||||
version = "1.0.0b260116"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
import importlib.metadata
|
||||
|
||||
from ._app import AgentFunctionApp
|
||||
from ._callbacks import AgentCallbackContext, AgentResponseCallbackProtocol
|
||||
from ._orchestration import DurableAIAgent
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
@@ -12,9 +10,6 @@ except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0" # Fallback for development mode
|
||||
|
||||
__all__ = [
|
||||
"AgentCallbackContext",
|
||||
"AgentFunctionApp",
|
||||
"AgentResponseCallbackProtocol",
|
||||
"DurableAIAgent",
|
||||
"__version__",
|
||||
]
|
||||
|
||||
@@ -8,16 +8,16 @@ with Azure Durable Entities, enabling stateful and durable AI agent execution.
|
||||
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any, TypeVar, cast
|
||||
|
||||
import azure.durable_functions as df
|
||||
import azure.functions as func
|
||||
from agent_framework import AgentProtocol, get_logger
|
||||
|
||||
from ._callbacks import AgentResponseCallbackProtocol
|
||||
from ._constants import (
|
||||
from agent_framework_durabletask import (
|
||||
DEFAULT_MAX_POLL_RETRIES,
|
||||
DEFAULT_POLL_INTERVAL_SECONDS,
|
||||
MIMETYPE_APPLICATION_JSON,
|
||||
@@ -28,12 +28,17 @@ from ._constants import (
|
||||
THREAD_ID_HEADER,
|
||||
WAIT_FOR_RESPONSE_FIELD,
|
||||
WAIT_FOR_RESPONSE_HEADER,
|
||||
AgentResponseCallbackProtocol,
|
||||
AgentSessionId,
|
||||
ApiResponseFields,
|
||||
DurableAgentState,
|
||||
DurableAIAgent,
|
||||
RunRequest,
|
||||
)
|
||||
from ._durable_agent_state import DurableAgentState
|
||||
|
||||
from ._entities import create_agent_entity
|
||||
from ._errors import IncomingRequestError
|
||||
from ._models import AgentSessionId, RunRequest
|
||||
from ._orchestration import AgentOrchestrationContextType, DurableAIAgent
|
||||
from ._orchestration import AgentOrchestrationContextType, AgentTask, AzureFunctionsAgentExecutor
|
||||
|
||||
logger = get_logger("agent_framework.azurefunctions")
|
||||
|
||||
@@ -294,7 +299,7 @@ class AgentFunctionApp(DFAppBase):
|
||||
self,
|
||||
context: AgentOrchestrationContextType,
|
||||
agent_name: str,
|
||||
) -> DurableAIAgent:
|
||||
) -> DurableAIAgent[AgentTask]:
|
||||
"""Return a DurableAIAgent proxy for a registered agent.
|
||||
|
||||
Args:
|
||||
@@ -305,14 +310,15 @@ class AgentFunctionApp(DFAppBase):
|
||||
ValueError: If the requested agent has not been registered.
|
||||
|
||||
Returns:
|
||||
DurableAIAgent wrapper bound to the orchestration context.
|
||||
DurableAIAgent[AgentTask] wrapper bound to the orchestration context.
|
||||
"""
|
||||
normalized_name = str(agent_name)
|
||||
|
||||
if normalized_name not in self._agent_metadata:
|
||||
raise ValueError(f"Agent '{normalized_name}' is not registered with this app.")
|
||||
|
||||
return DurableAIAgent(context, normalized_name)
|
||||
executor = AzureFunctionsAgentExecutor(context)
|
||||
return DurableAIAgent(executor, normalized_name)
|
||||
|
||||
def _setup_agent_functions(
|
||||
self,
|
||||
@@ -375,8 +381,6 @@ class AgentFunctionApp(DFAppBase):
|
||||
"enable_tool_calls": true|false (optional, default: true)
|
||||
}
|
||||
"""
|
||||
logger.debug(f"[HTTP Trigger] Received request on route: /api/agents/{agent_name}/run")
|
||||
|
||||
request_response_format: str = REQUEST_RESPONSE_FORMAT_JSON
|
||||
thread_id: str | None = None
|
||||
|
||||
@@ -385,9 +389,9 @@ class AgentFunctionApp(DFAppBase):
|
||||
thread_id = self._resolve_thread_id(req=req, req_body=req_body)
|
||||
wait_for_response = self._should_wait_for_response(req=req, req_body=req_body)
|
||||
|
||||
logger.debug(f"[HTTP Trigger] Message: {message}")
|
||||
logger.debug(f"[HTTP Trigger] Thread ID: {thread_id}")
|
||||
logger.debug(f"[HTTP Trigger] wait_for_response: {wait_for_response}")
|
||||
logger.debug(
|
||||
f"[HTTP Trigger] Message: {message}, Thread ID: {thread_id}, wait_for_response: {wait_for_response}"
|
||||
)
|
||||
|
||||
if not message:
|
||||
logger.warning("[HTTP Trigger] Request rejected: Missing message")
|
||||
@@ -401,15 +405,18 @@ class AgentFunctionApp(DFAppBase):
|
||||
session_id = self._create_session_id(agent_name, thread_id)
|
||||
correlation_id = self._generate_unique_id()
|
||||
|
||||
logger.debug(f"[HTTP Trigger] Using session ID: {session_id}")
|
||||
logger.debug(f"[HTTP Trigger] Generated correlation ID: {correlation_id}")
|
||||
logger.debug("[HTTP Trigger] Calling entity to run agent...")
|
||||
logger.debug(
|
||||
f"[HTTP Trigger] Calling entity to run agent using session ID: {session_id} "
|
||||
f"and correlation ID: {correlation_id}"
|
||||
)
|
||||
|
||||
entity_instance_id = session_id.to_entity_id()
|
||||
entity_instance_id = df.EntityId(
|
||||
name=session_id.entity_name,
|
||||
key=session_id.key,
|
||||
)
|
||||
run_request = self._build_request_data(
|
||||
req_body,
|
||||
message,
|
||||
thread_id,
|
||||
correlation_id,
|
||||
request_response_format,
|
||||
)
|
||||
@@ -622,14 +629,16 @@ class AgentFunctionApp(DFAppBase):
|
||||
session_id = AgentSessionId.with_random_key(agent_name)
|
||||
|
||||
# Build entity instance ID
|
||||
entity_instance_id = session_id.to_entity_id()
|
||||
entity_instance_id = df.EntityId(
|
||||
name=session_id.entity_name,
|
||||
key=session_id.key,
|
||||
)
|
||||
|
||||
# Create run request
|
||||
correlation_id = self._generate_unique_id()
|
||||
run_request = self._build_request_data(
|
||||
req_body={"message": query, "role": "user"},
|
||||
message=query,
|
||||
thread_id=str(session_id),
|
||||
correlation_id=correlation_id,
|
||||
request_response_format=REQUEST_RESPONSE_FORMAT_TEXT,
|
||||
)
|
||||
@@ -781,7 +790,7 @@ class AgentFunctionApp(DFAppBase):
|
||||
agent_response = state.try_get_agent_response(correlation_id)
|
||||
if agent_response:
|
||||
result = self._build_success_result(
|
||||
response_data=agent_response,
|
||||
response_message=agent_response.text,
|
||||
message=message,
|
||||
thread_id=thread_id,
|
||||
correlation_id=correlation_id,
|
||||
@@ -827,23 +836,22 @@ class AgentFunctionApp(DFAppBase):
|
||||
)
|
||||
|
||||
def _build_success_result(
|
||||
self, response_data: dict[str, Any], message: str, thread_id: str, correlation_id: str, state: DurableAgentState
|
||||
self, response_message: str, message: str, thread_id: str, correlation_id: str, state: DurableAgentState
|
||||
) -> dict[str, Any]:
|
||||
"""Build the success result returned to the HTTP caller."""
|
||||
return self._build_response_payload(
|
||||
response=response_data.get("content"),
|
||||
response=response_message,
|
||||
message=message,
|
||||
thread_id=thread_id,
|
||||
status="success",
|
||||
correlation_id=correlation_id,
|
||||
extra_fields={"message_count": response_data.get("message_count", state.message_count)},
|
||||
extra_fields={ApiResponseFields.MESSAGE_COUNT: state.message_count},
|
||||
)
|
||||
|
||||
def _build_request_data(
|
||||
self,
|
||||
req_body: dict[str, Any],
|
||||
message: str,
|
||||
thread_id: str,
|
||||
correlation_id: str,
|
||||
request_response_format: str,
|
||||
) -> dict[str, Any]:
|
||||
@@ -857,8 +865,8 @@ class AgentFunctionApp(DFAppBase):
|
||||
request_response_format=request_response_format,
|
||||
response_format=req_body.get("response_format"),
|
||||
enable_tool_calls=enable_tool_calls,
|
||||
thread_id=thread_id,
|
||||
correlation_id=correlation_id,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
).to_dict()
|
||||
|
||||
def _build_accepted_response(self, message: str, thread_id: str, correlation_id: str) -> dict[str, Any]:
|
||||
@@ -910,15 +918,13 @@ class AgentFunctionApp(DFAppBase):
|
||||
|
||||
def _generate_unique_id(self) -> str:
|
||||
"""Generate a new unique identifier."""
|
||||
import uuid
|
||||
|
||||
return uuid.uuid4().hex
|
||||
|
||||
def _create_session_id(self, func_name: str, thread_id: str | None) -> AgentSessionId:
|
||||
def _create_session_id(self, agent_name: str, thread_id: str | None) -> AgentSessionId:
|
||||
"""Create a session identifier using the provided thread id or a random value."""
|
||||
if thread_id:
|
||||
return AgentSessionId(name=func_name, key=thread_id)
|
||||
return AgentSessionId.with_random_key(name=func_name)
|
||||
return AgentSessionId(name=agent_name, key=thread_id)
|
||||
return AgentSessionId.with_random_key(name=agent_name)
|
||||
|
||||
def _resolve_thread_id(self, req: func.HttpRequest, req_body: dict[str, Any]) -> str:
|
||||
"""Retrieve the thread identifier from request body or query parameters."""
|
||||
|
||||
@@ -8,346 +8,41 @@ allows for long-running agent conversations.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
from collections.abc import AsyncIterable, Callable
|
||||
from collections.abc import Callable
|
||||
from typing import Any, cast
|
||||
|
||||
import azure.durable_functions as df
|
||||
from agent_framework import (
|
||||
AgentProtocol,
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
ChatMessage,
|
||||
Content,
|
||||
Role,
|
||||
get_logger,
|
||||
from agent_framework import AgentProtocol, get_logger
|
||||
from agent_framework_durabletask import (
|
||||
AgentEntity,
|
||||
AgentEntityStateProviderMixin,
|
||||
AgentResponseCallbackProtocol,
|
||||
)
|
||||
|
||||
from ._callbacks import AgentCallbackContext, AgentResponseCallbackProtocol
|
||||
from ._durable_agent_state import (
|
||||
DurableAgentState,
|
||||
DurableAgentStateData,
|
||||
DurableAgentStateEntry,
|
||||
DurableAgentStateRequest,
|
||||
DurableAgentStateResponse,
|
||||
)
|
||||
from ._models import RunRequest
|
||||
|
||||
logger = get_logger("agent_framework.azurefunctions.entities")
|
||||
|
||||
|
||||
class AgentEntity:
|
||||
"""Durable entity that manages agent execution and conversation state.
|
||||
class AzureFunctionEntityStateProvider(AgentEntityStateProviderMixin):
|
||||
"""Azure Functions Durable Entity state provider for AgentEntity.
|
||||
|
||||
This entity:
|
||||
- Maintains conversation history
|
||||
- Executes agent with messages
|
||||
- Stores agent responses
|
||||
- Handles tool execution
|
||||
|
||||
Operations:
|
||||
- run: Execute the agent with a message
|
||||
- run_agent: (Deprecated) Execute the agent with a message
|
||||
- reset: Clear conversation history
|
||||
|
||||
Attributes:
|
||||
agent: The AgentProtocol instance
|
||||
state: The DurableAgentState managing conversation history
|
||||
This class utilizes the Durable Entity context from `azure-functions-durable` package
|
||||
to get and set the state of the agent entity.
|
||||
"""
|
||||
|
||||
agent: AgentProtocol
|
||||
state: DurableAgentState
|
||||
def __init__(self, context: df.DurableEntityContext) -> None:
|
||||
self._context = context
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent: AgentProtocol,
|
||||
callback: AgentResponseCallbackProtocol | None = None,
|
||||
):
|
||||
"""Initialize the agent entity.
|
||||
def _get_state_dict(self) -> dict[str, Any]:
|
||||
raw_state = self._context.get_state(lambda: {})
|
||||
if not isinstance(raw_state, dict):
|
||||
return {}
|
||||
return cast(dict[str, Any], raw_state)
|
||||
|
||||
Args:
|
||||
agent: The Microsoft Agent Framework agent instance (must implement AgentProtocol)
|
||||
callback: Optional callback invoked during streaming updates and final responses
|
||||
"""
|
||||
self.agent = agent
|
||||
self.state = DurableAgentState()
|
||||
self.callback = callback
|
||||
def _set_state_dict(self, state: dict[str, Any]) -> None:
|
||||
self._context.set_state(state)
|
||||
|
||||
logger.debug(f"[AgentEntity] Initialized with agent type: {type(agent).__name__}")
|
||||
|
||||
def _is_error_response(self, entry: DurableAgentStateEntry) -> bool:
|
||||
"""Check if a conversation history entry is an error response.
|
||||
|
||||
Error responses should be kept in history for tracking but not sent to the agent
|
||||
since Azure OpenAI doesn't support 'error' content type.
|
||||
|
||||
Args:
|
||||
entry: A conversation history entry (DurableAgentStateEntry or dict)
|
||||
|
||||
Returns:
|
||||
True if the entry is a response containing error content, False otherwise
|
||||
"""
|
||||
if isinstance(entry, DurableAgentStateResponse):
|
||||
return entry.is_error
|
||||
return False
|
||||
|
||||
async def run_agent(
|
||||
self,
|
||||
context: df.DurableEntityContext,
|
||||
request: RunRequest | dict[str, Any] | str,
|
||||
) -> AgentResponse:
|
||||
"""(Deprecated) Execute the agent with a message directly in the entity.
|
||||
|
||||
Args:
|
||||
context: Entity context
|
||||
request: RunRequest object, dict, or string message (for backward compatibility)
|
||||
|
||||
Returns:
|
||||
AgentResponse enriched with execution metadata.
|
||||
"""
|
||||
return await self.run(context, request)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
context: df.DurableEntityContext,
|
||||
request: RunRequest | dict[str, Any] | str,
|
||||
) -> AgentResponse:
|
||||
"""Execute the agent with a message directly in the entity.
|
||||
|
||||
Args:
|
||||
context: Entity context
|
||||
request: RunRequest object, dict, or string message (for backward compatibility)
|
||||
|
||||
Returns:
|
||||
AgentResponse enriched with execution metadata.
|
||||
"""
|
||||
if isinstance(request, str):
|
||||
run_request = RunRequest(message=request, role=Role.USER)
|
||||
elif isinstance(request, dict):
|
||||
run_request = RunRequest.from_dict(request)
|
||||
else:
|
||||
run_request = request
|
||||
|
||||
message = run_request.message
|
||||
thread_id = run_request.thread_id
|
||||
correlation_id = run_request.correlation_id
|
||||
if not thread_id:
|
||||
raise ValueError("RunRequest must include a thread_id")
|
||||
if not correlation_id:
|
||||
raise ValueError("RunRequest must include a correlation_id")
|
||||
response_format = run_request.response_format
|
||||
enable_tool_calls = run_request.enable_tool_calls
|
||||
|
||||
state_request = DurableAgentStateRequest.from_run_request(run_request)
|
||||
self.state.data.conversation_history.append(state_request)
|
||||
|
||||
logger.debug(f"[AgentEntity.run] Received Message: {state_request}")
|
||||
|
||||
try:
|
||||
# Build messages from conversation history, excluding error responses
|
||||
# Error responses are kept in history for tracking but not sent to the agent
|
||||
chat_messages: list[ChatMessage] = [
|
||||
m.to_chat_message()
|
||||
for entry in self.state.data.conversation_history
|
||||
if not self._is_error_response(entry)
|
||||
for m in entry.messages
|
||||
]
|
||||
|
||||
run_kwargs: dict[str, Any] = {"messages": chat_messages, "options": {}}
|
||||
if not enable_tool_calls:
|
||||
run_kwargs["options"]["tools"] = None
|
||||
if response_format:
|
||||
run_kwargs["options"]["response_format"] = response_format
|
||||
|
||||
agent_response: AgentResponse = await self._invoke_agent(
|
||||
run_kwargs=run_kwargs,
|
||||
correlation_id=correlation_id,
|
||||
thread_id=thread_id,
|
||||
request_message=message,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"[AgentEntity.run] Agent invocation completed - response type: %s",
|
||||
type(agent_response).__name__,
|
||||
)
|
||||
|
||||
try:
|
||||
response_text = agent_response.text if agent_response.text else "No response"
|
||||
logger.debug(f"Response: {response_text[:100]}...")
|
||||
except Exception as extraction_error:
|
||||
logger.error(
|
||||
"Error extracting response text: %s",
|
||||
extraction_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
state_response = DurableAgentStateResponse.from_run_response(correlation_id, agent_response)
|
||||
self.state.data.conversation_history.append(state_response)
|
||||
|
||||
logger.debug("[AgentEntity.run] AgentResponse stored in conversation history")
|
||||
|
||||
return agent_response
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("[AgentEntity.run] Agent execution failed.")
|
||||
|
||||
# Create error message
|
||||
error_message = ChatMessage(
|
||||
role=Role.ASSISTANT, contents=[Content.from_error(message=str(exc), error_code=type(exc).__name__)]
|
||||
)
|
||||
|
||||
error_response = AgentResponse(messages=[error_message])
|
||||
|
||||
# Create and store error response in conversation history
|
||||
error_state_response = DurableAgentStateResponse.from_run_response(correlation_id, error_response)
|
||||
error_state_response.is_error = True
|
||||
self.state.data.conversation_history.append(error_state_response)
|
||||
|
||||
return error_response
|
||||
|
||||
async def _invoke_agent(
|
||||
self,
|
||||
run_kwargs: dict[str, Any],
|
||||
correlation_id: str,
|
||||
thread_id: str,
|
||||
request_message: str,
|
||||
) -> AgentResponse:
|
||||
"""Execute the agent, preferring streaming when available."""
|
||||
callback_context: AgentCallbackContext | None = None
|
||||
if self.callback is not None:
|
||||
callback_context = self._build_callback_context(
|
||||
correlation_id=correlation_id,
|
||||
thread_id=thread_id,
|
||||
request_message=request_message,
|
||||
)
|
||||
|
||||
run_stream_callable = getattr(self.agent, "run_stream", None)
|
||||
if callable(run_stream_callable):
|
||||
try:
|
||||
stream_candidate = run_stream_callable(**run_kwargs)
|
||||
if inspect.isawaitable(stream_candidate):
|
||||
stream_candidate = await stream_candidate
|
||||
|
||||
return await self._consume_stream(
|
||||
stream=cast(AsyncIterable[AgentResponseUpdate], stream_candidate),
|
||||
callback_context=callback_context,
|
||||
)
|
||||
except TypeError as type_error:
|
||||
if "__aiter__" not in str(type_error):
|
||||
raise
|
||||
logger.debug(
|
||||
"run_stream returned a non-async result; falling back to run(): %s",
|
||||
type_error,
|
||||
)
|
||||
except Exception as stream_error:
|
||||
logger.warning(
|
||||
"run_stream failed; falling back to run(): %s",
|
||||
stream_error,
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
logger.debug("Agent does not expose run_stream; falling back to run().")
|
||||
|
||||
agent_response = await self._invoke_non_stream(run_kwargs)
|
||||
await self._notify_final_response(agent_response, callback_context)
|
||||
return agent_response
|
||||
|
||||
async def _consume_stream(
|
||||
self,
|
||||
stream: AsyncIterable[AgentResponseUpdate],
|
||||
callback_context: AgentCallbackContext | None = None,
|
||||
) -> AgentResponse:
|
||||
"""Consume streaming responses and build the final AgentResponse."""
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
|
||||
async for update in stream:
|
||||
updates.append(update)
|
||||
await self._notify_stream_update(update, callback_context)
|
||||
|
||||
if updates:
|
||||
response = AgentResponse.from_agent_run_response_updates(updates)
|
||||
else:
|
||||
logger.debug("[AgentEntity] No streaming updates received; creating empty response")
|
||||
response = AgentResponse(messages=[])
|
||||
|
||||
await self._notify_final_response(response, callback_context)
|
||||
return response
|
||||
|
||||
async def _invoke_non_stream(self, run_kwargs: dict[str, Any]) -> AgentResponse:
|
||||
"""Invoke the agent without streaming support."""
|
||||
run_callable = getattr(self.agent, "run", None)
|
||||
if run_callable is None or not callable(run_callable):
|
||||
raise AttributeError("Agent does not implement run() method")
|
||||
|
||||
result = run_callable(**run_kwargs)
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
|
||||
if not isinstance(result, AgentResponse):
|
||||
raise TypeError(f"Agent run() must return an AgentResponse instance; received {type(result).__name__}")
|
||||
|
||||
return result
|
||||
|
||||
async def _notify_stream_update(
|
||||
self,
|
||||
update: AgentResponseUpdate,
|
||||
context: AgentCallbackContext | None,
|
||||
) -> None:
|
||||
"""Invoke the streaming callback if one is registered."""
|
||||
if self.callback is None or context is None:
|
||||
return
|
||||
|
||||
try:
|
||||
callback_result = self.callback.on_streaming_response_update(update, context)
|
||||
if inspect.isawaitable(callback_result):
|
||||
await callback_result
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[AgentEntity] Streaming callback raised an exception: %s",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def _notify_final_response(
|
||||
self,
|
||||
response: AgentResponse,
|
||||
context: AgentCallbackContext | None,
|
||||
) -> None:
|
||||
"""Invoke the final response callback if one is registered."""
|
||||
if self.callback is None or context is None:
|
||||
return
|
||||
|
||||
try:
|
||||
callback_result = self.callback.on_agent_response(response, context)
|
||||
if inspect.isawaitable(callback_result):
|
||||
await callback_result
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[AgentEntity] Response callback raised an exception: %s",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _build_callback_context(
|
||||
self,
|
||||
correlation_id: str,
|
||||
thread_id: str,
|
||||
request_message: str,
|
||||
) -> AgentCallbackContext:
|
||||
"""Create the callback context provided to consumers."""
|
||||
agent_name = getattr(self.agent, "name", None) or type(self.agent).__name__
|
||||
return AgentCallbackContext(
|
||||
agent_name=agent_name,
|
||||
correlation_id=correlation_id,
|
||||
thread_id=thread_id,
|
||||
request_message=request_message,
|
||||
)
|
||||
|
||||
def reset(self, context: df.DurableEntityContext) -> None:
|
||||
"""Reset the entity state (clear conversation history)."""
|
||||
logger.debug("[AgentEntity.reset] Resetting entity state")
|
||||
self.state.data = DurableAgentStateData(conversation_history=[])
|
||||
logger.debug("[AgentEntity.reset] State reset complete")
|
||||
def _get_thread_id_from_entity(self) -> str:
|
||||
return self._context.entity_key
|
||||
|
||||
|
||||
def create_agent_entity(
|
||||
@@ -368,19 +63,10 @@ def create_agent_entity(
|
||||
"""Async handler that executes the entity operations."""
|
||||
try:
|
||||
logger.debug("[entity_function] Entity triggered")
|
||||
logger.debug(f"[entity_function] Operation: {context.operation_name}")
|
||||
logger.debug("[entity_function] Operation: %s", context.operation_name)
|
||||
|
||||
current_state = context.get_state(lambda: None)
|
||||
logger.debug("Retrieved state: %s", str(current_state)[:100])
|
||||
entity = AgentEntity(agent, callback)
|
||||
|
||||
if current_state is not None:
|
||||
entity.state = DurableAgentState.from_dict(current_state)
|
||||
logger.debug(
|
||||
"[entity_function] Restored entity from state (message_count: %s)", entity.state.message_count
|
||||
)
|
||||
else:
|
||||
logger.debug("[entity_function] Created new entity instance")
|
||||
state_provider = AzureFunctionEntityStateProvider(context)
|
||||
entity = AgentEntity(agent, callback, state_provider=state_provider)
|
||||
|
||||
operation = context.operation_name
|
||||
|
||||
@@ -394,21 +80,18 @@ def create_agent_entity(
|
||||
# Fall back to treating input as message string
|
||||
request = "" if input_data is None else str(cast(object, input_data))
|
||||
|
||||
result = await entity.run(context, request)
|
||||
result = await entity.run(request)
|
||||
context.set_result(result.to_dict())
|
||||
|
||||
elif operation == "reset":
|
||||
entity.reset(context)
|
||||
entity.reset()
|
||||
context.set_result({"status": "reset"})
|
||||
|
||||
else:
|
||||
logger.error("[entity_function] Unknown operation: %s", operation)
|
||||
context.set_result({"error": f"Unknown operation: {operation}"})
|
||||
|
||||
serialized_state = entity.state.to_dict()
|
||||
logger.debug("State dict: %s", serialized_state)
|
||||
context.set_state(serialized_state)
|
||||
logger.info(f"[entity_function] Operation {operation} completed successfully")
|
||||
logger.info("[entity_function] Operation %s completed successfully", operation)
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("[entity_function] Error executing entity operation %s", exc)
|
||||
|
||||
@@ -5,24 +5,22 @@
|
||||
This module provides support for using agents inside Durable Function orchestrations.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator, Callable, Sequence
|
||||
from typing import TYPE_CHECKING, Any, TypeAlias, cast
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any, TypeAlias
|
||||
|
||||
from agent_framework import (
|
||||
AgentProtocol,
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
AgentThread,
|
||||
ChatMessage,
|
||||
get_logger,
|
||||
import azure.durable_functions as df
|
||||
from agent_framework import AgentThread, get_logger
|
||||
from agent_framework_durabletask import (
|
||||
DurableAgentExecutor,
|
||||
RunRequest,
|
||||
ensure_response_format,
|
||||
load_agent_response,
|
||||
)
|
||||
from azure.durable_functions.models import TaskBase
|
||||
from azure.durable_functions.models.actions.NoOpAction import NoOpAction
|
||||
from azure.durable_functions.models.Task import CompoundTask, TaskState
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ._models import AgentSessionId, DurableAgentThread, RunRequest
|
||||
|
||||
logger = get_logger("agent_framework.azurefunctions.orchestration")
|
||||
|
||||
CompoundActionConstructor: TypeAlias = Callable[[list[Any]], Any] | None
|
||||
@@ -45,6 +43,25 @@ else:
|
||||
_TypedCompoundTask = CompoundTask
|
||||
|
||||
|
||||
class PreCompletedTask(TaskBase):
|
||||
"""A simple task that is already completed with a result.
|
||||
|
||||
Used for fire-and-forget mode where we want to return immediately
|
||||
with an acceptance response without waiting for entity processing.
|
||||
"""
|
||||
|
||||
def __init__(self, result: Any):
|
||||
"""Initialize with a completed result.
|
||||
|
||||
Args:
|
||||
result: The result value for this completed task
|
||||
"""
|
||||
# Initialize with a NoOp action since we don't need actual orchestration actions
|
||||
super().__init__(-1, NoOpAction())
|
||||
# Immediately mark as completed with the result
|
||||
self.set_value(is_error=False, value=result)
|
||||
|
||||
|
||||
class AgentTask(_TypedCompoundTask):
|
||||
"""A custom Task that wraps entity calls and provides typed AgentResponse results.
|
||||
|
||||
@@ -65,10 +82,13 @@ class AgentTask(_TypedCompoundTask):
|
||||
response_format: Optional Pydantic model for response parsing
|
||||
correlation_id: Correlation ID for logging
|
||||
"""
|
||||
super().__init__([entity_task])
|
||||
# Set instance variables BEFORE calling super().__init__
|
||||
# because super().__init__ may trigger try_set_value for pre-completed tasks
|
||||
self._response_format = response_format
|
||||
self._correlation_id = correlation_id
|
||||
|
||||
super().__init__([entity_task])
|
||||
|
||||
# Override action_repr to expose the inner task's action directly
|
||||
# This ensures compatibility with ReplaySchema V3 which expects Action objects.
|
||||
self.action_repr = entity_task.action_repr
|
||||
@@ -95,10 +115,10 @@ class AgentTask(_TypedCompoundTask):
|
||||
)
|
||||
|
||||
try:
|
||||
response = self._load_agent_response(raw_result)
|
||||
response = load_agent_response(raw_result)
|
||||
|
||||
if self._response_format is not None:
|
||||
self._ensure_response_format(
|
||||
ensure_response_format(
|
||||
self._response_format,
|
||||
self._correlation_id,
|
||||
response,
|
||||
@@ -118,230 +138,84 @@ class AgentTask(_TypedCompoundTask):
|
||||
self._first_error = child.result
|
||||
self.set_value(is_error=True, value=self._first_error)
|
||||
|
||||
def _load_agent_response(self, agent_response: AgentResponse | dict[str, Any] | None) -> AgentResponse:
|
||||
"""Convert raw payloads into AgentResponse instance."""
|
||||
if agent_response is None:
|
||||
raise ValueError("agent_response cannot be None")
|
||||
|
||||
logger.debug("[load_agent_response] Loading agent response of type: %s", type(agent_response))
|
||||
class AzureFunctionsAgentExecutor(DurableAgentExecutor[AgentTask]):
|
||||
"""Executor that executes durable agents inside Azure Functions orchestrations."""
|
||||
|
||||
if isinstance(agent_response, AgentResponse):
|
||||
return agent_response
|
||||
if isinstance(agent_response, dict):
|
||||
logger.debug("[load_agent_response] Converting dict payload using AgentResponse.from_dict")
|
||||
return AgentResponse.from_dict(agent_response)
|
||||
|
||||
raise TypeError(f"Unsupported type for agent_response: {type(agent_response)}")
|
||||
|
||||
def _ensure_response_format(
|
||||
self,
|
||||
response_format: type[BaseModel] | None,
|
||||
correlation_id: str,
|
||||
response: AgentResponse,
|
||||
) -> None:
|
||||
"""Ensure the AgentResponse value is parsed into the expected response_format."""
|
||||
if response_format is not None and not isinstance(response.value, response_format):
|
||||
response.try_parse_value(response_format)
|
||||
|
||||
logger.debug(
|
||||
"[DurableAIAgent] Loaded AgentResponse.value for correlation_id %s with type: %s",
|
||||
correlation_id,
|
||||
type(response.value).__name__,
|
||||
)
|
||||
|
||||
|
||||
class DurableAIAgent(AgentProtocol):
|
||||
"""A durable agent implementation that uses entity methods to interact with agent entities.
|
||||
|
||||
This class implements AgentProtocol and provides methods to work with Azure Durable Functions
|
||||
orchestrations, which use generators and yield instead of async/await.
|
||||
|
||||
Key methods:
|
||||
- get_new_thread(): Create a new conversation thread
|
||||
- run(): Execute the agent and return a Task for yielding in orchestrations
|
||||
|
||||
Note: The run() method is NOT async. It returns a Task directly that must be
|
||||
yielded in orchestrations to wait for the entity call to complete.
|
||||
|
||||
Example usage in orchestration:
|
||||
writer = app.get_agent(context, "WriterAgent")
|
||||
thread = writer.get_new_thread() # NOT yielded - returns immediately
|
||||
|
||||
response = yield writer.run( # Yielded - waits for entity call
|
||||
message="Write a haiku about coding",
|
||||
thread=thread
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(self, context: AgentOrchestrationContextType, agent_name: str):
|
||||
"""Initialize the DurableAIAgent.
|
||||
|
||||
Args:
|
||||
context: The orchestration context
|
||||
agent_name: Name of the agent (used to construct entity ID)
|
||||
"""
|
||||
def __init__(self, context: AgentOrchestrationContextType):
|
||||
self.context = context
|
||||
self.agent_name = agent_name
|
||||
self.id = str(uuid.uuid4())
|
||||
self.name = agent_name
|
||||
self.description = f"Durable agent proxy for {agent_name}"
|
||||
logger.debug("[DurableAIAgent] Initialized for agent: %s", agent_name)
|
||||
|
||||
# We return an AgentTask here which is a TaskBase subclass.
|
||||
# This is an intentional deviation from AgentProtocol which defines run() as async.
|
||||
# The AgentTask can be yielded in Durable Functions orchestrations and will provide
|
||||
# a typed AgentResponse result.
|
||||
def run( # type: ignore[override]
|
||||
def generate_unique_id(self) -> str:
|
||||
return str(self.context.new_uuid())
|
||||
|
||||
def get_run_request(
|
||||
self,
|
||||
messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
|
||||
message: str,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
options: dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentTask:
|
||||
"""Execute the agent with messages and return an AgentTask for orchestrations.
|
||||
|
||||
This method implements AgentProtocol and returns an AgentTask (subclass of TaskBase)
|
||||
that can be yielded in Durable Functions orchestrations. The task's result will be
|
||||
a typed AgentResponse.
|
||||
) -> RunRequest:
|
||||
"""Get the current run request from the orchestration context.
|
||||
|
||||
Args:
|
||||
messages: The message(s) to send to the agent
|
||||
thread: Optional agent thread for conversation context
|
||||
options: Optional dict containing chat options like response_format, tools, etc.
|
||||
**kwargs: Additional arguments (enable_tool_calls)
|
||||
message: The message to send to the agent
|
||||
options: Optional options dictionary. Supported keys include
|
||||
``response_format``, ``enable_tool_calls``, and ``wait_for_response``.
|
||||
Additional keys are forwarded to the agent execution.
|
||||
|
||||
Returns:
|
||||
An AgentTask that resolves to an AgentResponse when yielded
|
||||
RunRequest: The current run request
|
||||
|
||||
Example:
|
||||
@app.orchestration_trigger(context_name="context")
|
||||
def my_orchestration(context):
|
||||
agent = app.get_agent(context, "MyAgent")
|
||||
thread = agent.get_new_thread()
|
||||
response = yield agent.run("Hello", thread=thread, options={"response_format": MyModel})
|
||||
# response is typed as AgentResponse
|
||||
Raises:
|
||||
ValueError: If wait_for_response=False (not supported in orchestrations)
|
||||
"""
|
||||
message_str = self._normalize_messages(messages)
|
||||
# Create a copy to avoid modifying the caller's dict
|
||||
|
||||
# Extract options from the options dict (aligned with ChatAgent pattern)
|
||||
opts = options or {}
|
||||
response_format: type[BaseModel] | None = opts.get("response_format")
|
||||
enable_tool_calls = opts.get("enable_tool_calls", kwargs.get("enable_tool_calls", True))
|
||||
request = super().get_run_request(message, options=options)
|
||||
request.orchestration_id = self.context.instance_id
|
||||
return request
|
||||
|
||||
# Get the session ID for the entity
|
||||
if isinstance(thread, DurableAgentThread) and thread.session_id is not None:
|
||||
session_id = thread.session_id
|
||||
else:
|
||||
# Create a unique session ID for each call when no thread is provided
|
||||
# This ensures each call gets its own conversation context
|
||||
session_key = str(self.context.new_uuid())
|
||||
session_id = AgentSessionId(name=self.agent_name, key=session_key)
|
||||
logger.debug("[DurableAIAgent] No thread provided, created unique session_id: %s", session_id)
|
||||
def run_durable_agent(
|
||||
self,
|
||||
agent_name: str,
|
||||
run_request: RunRequest,
|
||||
thread: AgentThread | None = None,
|
||||
) -> AgentTask:
|
||||
|
||||
# Create entity ID from session ID
|
||||
entity_id = session_id.to_entity_id()
|
||||
# Resolve session
|
||||
session_id = self._create_session_id(agent_name, thread)
|
||||
|
||||
entity_id = df.EntityId(
|
||||
name=session_id.entity_name,
|
||||
key=session_id.key,
|
||||
)
|
||||
|
||||
# Generate a deterministic correlation ID for this call
|
||||
# This is required by the entity and must be unique per call
|
||||
correlation_id = str(self.context.new_uuid())
|
||||
logger.debug(
|
||||
"[DurableAIAgent] Using correlation_id: %s for entity_id: %s for session_id: %s",
|
||||
correlation_id,
|
||||
"[AzureFunctionsAgentProvider] correlation_id: %s entity_id: %s session_id: %s",
|
||||
run_request.correlation_id,
|
||||
entity_id,
|
||||
session_id,
|
||||
)
|
||||
|
||||
# Prepare the request using RunRequest model
|
||||
# Include the orchestration's instance_id so it can be stored in the agent's entity state
|
||||
run_request = RunRequest(
|
||||
message=message_str,
|
||||
enable_tool_calls=enable_tool_calls,
|
||||
correlation_id=correlation_id,
|
||||
thread_id=session_id.key,
|
||||
response_format=response_format,
|
||||
orchestration_id=self.context.instance_id,
|
||||
)
|
||||
# Branch based on wait_for_response
|
||||
if not run_request.wait_for_response:
|
||||
# Fire-and-forget mode: signal entity and return pre-completed task
|
||||
logger.debug(
|
||||
"[AzureFunctionsAgentExecutor] Fire-and-forget mode: signaling entity (correlation: %s)",
|
||||
run_request.correlation_id,
|
||||
)
|
||||
self.context.signal_entity(entity_id, "run", run_request.to_dict())
|
||||
|
||||
logger.debug("[DurableAIAgent] Calling entity %s with message: %s", entity_id, message_str[:100])
|
||||
# Create acceptance response using base class helper
|
||||
acceptance_response = self._create_acceptance_response(run_request.correlation_id)
|
||||
|
||||
# Call the entity to get the underlying task
|
||||
entity_task = self.context.call_entity(entity_id, "run", run_request.to_dict())
|
||||
# Create a pre-completed task with the acceptance response
|
||||
entity_task = PreCompletedTask(acceptance_response)
|
||||
else:
|
||||
# Blocking mode: call entity and wait for response
|
||||
entity_task = self.context.call_entity(entity_id, "run", run_request.to_dict())
|
||||
|
||||
# Wrap it in an AgentTask that will convert the result to AgentResponse
|
||||
agent_task = AgentTask(
|
||||
return AgentTask(
|
||||
entity_task=entity_task,
|
||||
response_format=response_format,
|
||||
correlation_id=correlation_id,
|
||||
response_format=run_request.response_format,
|
||||
correlation_id=run_request.correlation_id,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"[DurableAIAgent] Created AgentTask for correlation_id %s",
|
||||
correlation_id,
|
||||
)
|
||||
|
||||
return agent_task
|
||||
|
||||
def run_stream(
|
||||
self,
|
||||
messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[AgentResponseUpdate]:
|
||||
"""Run the agent with streaming (not supported for durable agents).
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Streaming is not supported for durable agents.
|
||||
"""
|
||||
raise NotImplementedError("Streaming is not supported for durable agents in orchestrations.")
|
||||
|
||||
def get_new_thread(self, **kwargs: Any) -> AgentThread:
|
||||
"""Create a new agent thread for this orchestration instance.
|
||||
|
||||
Each call creates a unique thread with its own conversation context.
|
||||
The session ID is deterministic (uses context.new_uuid()) to ensure
|
||||
orchestration replay works correctly.
|
||||
|
||||
Returns:
|
||||
A new AgentThread instance with a unique session ID
|
||||
"""
|
||||
# Generate a deterministic unique key for this thread
|
||||
# Using context.new_uuid() ensures the same GUID is generated during replay
|
||||
session_key = str(self.context.new_uuid())
|
||||
|
||||
# Create AgentSessionId with agent name and session key
|
||||
session_id = AgentSessionId(name=self.agent_name, key=session_key)
|
||||
|
||||
thread = DurableAgentThread.from_session_id(session_id, **kwargs)
|
||||
|
||||
logger.debug("[DurableAIAgent] Created new thread with session_id: %s", session_id)
|
||||
return thread
|
||||
|
||||
def _messages_to_string(self, messages: list[ChatMessage]) -> str:
|
||||
"""Convert a list of ChatMessage objects to a single string.
|
||||
|
||||
Args:
|
||||
messages: List of ChatMessage objects
|
||||
|
||||
Returns:
|
||||
Concatenated string of message contents
|
||||
"""
|
||||
return "\n".join([msg.text or "" for msg in messages])
|
||||
|
||||
def _normalize_messages(self, messages: str | ChatMessage | Sequence[str | ChatMessage] | None) -> str:
|
||||
"""Convert supported message inputs to a single string."""
|
||||
if messages is None:
|
||||
return ""
|
||||
if isinstance(messages, str):
|
||||
return messages
|
||||
if isinstance(messages, ChatMessage):
|
||||
return messages.text or ""
|
||||
if isinstance(messages, list):
|
||||
if not messages:
|
||||
return ""
|
||||
first_item = messages[0]
|
||||
if isinstance(first_item, str):
|
||||
return "\n".join(cast(list[str], messages))
|
||||
return self._messages_to_string(cast(list[ChatMessage], messages))
|
||||
return str(messages)
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260123"
|
||||
version = "1.0.0b260116"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,6 +23,7 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core",
|
||||
"agent-framework-durabletask",
|
||||
"azure-functions",
|
||||
"azure-functions-durable",
|
||||
]
|
||||
|
||||
@@ -15,10 +15,9 @@ Usage:
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from agent_framework_durabletask import THREAD_ID_HEADER
|
||||
from testutils import SampleTestHelper, skip_if_azure_functions_integration_tests_disabled
|
||||
|
||||
from agent_framework_azurefunctions._constants import THREAD_ID_HEADER
|
||||
|
||||
# Module-level markers - applied to all tests in this file
|
||||
pytestmark = [
|
||||
pytest.mark.sample("01_single_agent"),
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
"""Unit tests for AgentFunctionApp."""
|
||||
|
||||
# pyright: reportPrivateUsage=false
|
||||
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, TypeVar
|
||||
@@ -11,20 +13,42 @@ import azure.durable_functions as df
|
||||
import azure.functions as func
|
||||
import pytest
|
||||
from agent_framework import AgentResponse, ChatMessage
|
||||
|
||||
from agent_framework_azurefunctions import AgentFunctionApp
|
||||
from agent_framework_azurefunctions._app import WAIT_FOR_RESPONSE_FIELD, WAIT_FOR_RESPONSE_HEADER
|
||||
from agent_framework_azurefunctions._constants import (
|
||||
from agent_framework_durabletask import (
|
||||
MIMETYPE_APPLICATION_JSON,
|
||||
MIMETYPE_TEXT_PLAIN,
|
||||
THREAD_ID_HEADER,
|
||||
WAIT_FOR_RESPONSE_FIELD,
|
||||
WAIT_FOR_RESPONSE_HEADER,
|
||||
AgentEntity,
|
||||
AgentEntityStateProviderMixin,
|
||||
DurableAgentState,
|
||||
)
|
||||
from agent_framework_azurefunctions._durable_agent_state import DurableAgentState
|
||||
from agent_framework_azurefunctions._entities import AgentEntity, create_agent_entity
|
||||
|
||||
from agent_framework_azurefunctions import AgentFunctionApp
|
||||
from agent_framework_azurefunctions._entities import create_agent_entity
|
||||
|
||||
TFunc = TypeVar("TFunc", bound=Callable[..., Any])
|
||||
|
||||
|
||||
def _identity_decorator(func: TFunc) -> TFunc:
|
||||
return func
|
||||
|
||||
|
||||
class _InMemoryStateProvider(AgentEntityStateProviderMixin):
|
||||
def __init__(self, *, thread_id: str = "test-thread", initial_state: dict[str, Any] | None = None) -> None:
|
||||
self._thread_id = thread_id
|
||||
self._state_dict: dict[str, Any] = initial_state or {}
|
||||
|
||||
def _get_state_dict(self) -> dict[str, Any]:
|
||||
return self._state_dict
|
||||
|
||||
def _set_state_dict(self, state: dict[str, Any]) -> None:
|
||||
self._state_dict = state
|
||||
|
||||
def _get_thread_id_from_entity(self) -> str:
|
||||
return self._thread_id
|
||||
|
||||
|
||||
class TestAgentFunctionAppInit:
|
||||
"""Test suite for AgentFunctionApp initialization."""
|
||||
|
||||
@@ -88,7 +112,7 @@ class TestAgentFunctionAppInit:
|
||||
app.add_agent(mock_agent, callback=specific_callback)
|
||||
|
||||
setup_mock.assert_called_once()
|
||||
_, _, passed_callback, enable_http_endpoint, enable_mcp_tool_trigger = setup_mock.call_args[0]
|
||||
_, _, passed_callback, enable_http_endpoint, _enable_mcp_tool_trigger = setup_mock.call_args[0]
|
||||
assert passed_callback is specific_callback
|
||||
assert enable_http_endpoint is True
|
||||
|
||||
@@ -104,7 +128,7 @@ class TestAgentFunctionAppInit:
|
||||
app.add_agent(mock_agent)
|
||||
|
||||
setup_mock.assert_called_once()
|
||||
_, _, passed_callback, enable_http_endpoint, enable_mcp_tool_trigger = setup_mock.call_args[0]
|
||||
_, _, passed_callback, enable_http_endpoint, _enable_mcp_tool_trigger = setup_mock.call_args[0]
|
||||
assert passed_callback is default_callback
|
||||
assert enable_http_endpoint is True
|
||||
|
||||
@@ -119,7 +143,7 @@ class TestAgentFunctionAppInit:
|
||||
AgentFunctionApp(agents=[mock_agent], default_callback=default_callback)
|
||||
|
||||
setup_mock.assert_called_once()
|
||||
_, _, passed_callback, enable_http_endpoint, enable_mcp_tool_trigger = setup_mock.call_args[0]
|
||||
_, _, passed_callback, enable_http_endpoint, _enable_mcp_tool_trigger = setup_mock.call_args[0]
|
||||
assert passed_callback is default_callback
|
||||
assert enable_http_endpoint is True
|
||||
|
||||
@@ -335,13 +359,12 @@ class TestAgentEntityOperations:
|
||||
return_value=AgentResponse(messages=[ChatMessage(role="assistant", text="Test response")])
|
||||
)
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="test-conv-123"))
|
||||
|
||||
result = await entity.run(
|
||||
mock_context,
|
||||
{"message": "Test message", "thread_id": "test-conv-123", "correlationId": "corr-app-entity-1"},
|
||||
)
|
||||
result = await entity.run({
|
||||
"message": "Test message",
|
||||
"correlationId": "corr-app-entity-1",
|
||||
})
|
||||
|
||||
assert isinstance(result, AgentResponse)
|
||||
assert result.text == "Test response"
|
||||
@@ -354,22 +377,17 @@ class TestAgentEntityOperations:
|
||||
return_value=AgentResponse(messages=[ChatMessage(role="assistant", text="Response 1")])
|
||||
)
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="conv-1"))
|
||||
|
||||
# Send first message
|
||||
await entity.run(
|
||||
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-app-entity-2"}
|
||||
)
|
||||
await entity.run({"message": "Message 1", "correlationId": "corr-app-entity-2"})
|
||||
|
||||
# Each conversation turn creates 2 entries: request and response
|
||||
history = entity.state.data.conversation_history[0].messages # Request entry
|
||||
assert len(history) == 1 # Just the user message
|
||||
|
||||
# Send second message
|
||||
await entity.run(
|
||||
mock_context, {"message": "Message 2", "thread_id": "conv-2", "correlationId": "corr-app-entity-2b"}
|
||||
)
|
||||
await entity.run({"message": "Message 2", "correlationId": "corr-app-entity-2b"})
|
||||
|
||||
# Now we have 4 entries total (2 requests + 2 responses)
|
||||
# Access the first request entry
|
||||
@@ -393,32 +411,26 @@ class TestAgentEntityOperations:
|
||||
return_value=AgentResponse(messages=[ChatMessage(role="assistant", text="Response")])
|
||||
)
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="conv-1"))
|
||||
|
||||
assert len(entity.state.data.conversation_history) == 0
|
||||
|
||||
await entity.run(
|
||||
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-app-entity-3a"}
|
||||
)
|
||||
await entity.run({"message": "Message 1", "correlationId": "corr-app-entity-3a"})
|
||||
assert len(entity.state.data.conversation_history) == 2
|
||||
|
||||
await entity.run(
|
||||
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-app-entity-3b"}
|
||||
)
|
||||
await entity.run({"message": "Message 2", "correlationId": "corr-app-entity-3b"})
|
||||
assert len(entity.state.data.conversation_history) == 4
|
||||
|
||||
def test_entity_reset(self) -> None:
|
||||
"""Test that entity reset clears state."""
|
||||
mock_agent = Mock()
|
||||
entity = AgentEntity(mock_agent)
|
||||
entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider())
|
||||
|
||||
# Set some state
|
||||
entity.state = DurableAgentState()
|
||||
|
||||
# Reset
|
||||
mock_context = Mock()
|
||||
entity.reset(mock_context)
|
||||
entity.reset()
|
||||
|
||||
assert len(entity.state.data.conversation_history) == 0
|
||||
|
||||
@@ -447,7 +459,6 @@ class TestAgentEntityFactory:
|
||||
mock_context.operation_name = "run"
|
||||
mock_context.get_input.return_value = {
|
||||
"message": "Test message",
|
||||
"thread_id": "conv-123",
|
||||
"correlationId": "corr-app-factory-1",
|
||||
}
|
||||
mock_context.get_state.return_value = None
|
||||
@@ -475,7 +486,6 @@ class TestAgentEntityFactory:
|
||||
mock_context.operation_name = "run_agent"
|
||||
mock_context.get_input.return_value = {
|
||||
"message": "Test message",
|
||||
"thread_id": "conv-123",
|
||||
"correlationId": "corr-app-factory-1",
|
||||
}
|
||||
mock_context.get_state.return_value = None
|
||||
@@ -595,7 +605,11 @@ class TestAgentEntityFactory:
|
||||
}
|
||||
|
||||
mock_context = Mock()
|
||||
mock_context.operation_name = "reset"
|
||||
mock_context.operation_name = "run"
|
||||
mock_context.get_input.return_value = {
|
||||
"message": "Test message",
|
||||
"correlationId": "corr-restore-1",
|
||||
}
|
||||
mock_context.get_state.return_value = existing_state
|
||||
|
||||
with patch.object(DurableAgentState, "from_dict", wraps=DurableAgentState.from_dict) as from_dict_mock:
|
||||
@@ -612,12 +626,12 @@ class TestErrorHandling:
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(side_effect=Exception("Agent error"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="conv-1"))
|
||||
|
||||
result = await entity.run(
|
||||
mock_context, {"message": "Test message", "thread_id": "conv-1", "correlationId": "corr-app-error-1"}
|
||||
)
|
||||
result = await entity.run({
|
||||
"message": "Test message",
|
||||
"correlationId": "corr-app-error-1",
|
||||
})
|
||||
|
||||
assert isinstance(result, AgentResponse)
|
||||
assert len(result.messages) == 1
|
||||
@@ -636,6 +650,7 @@ class TestErrorHandling:
|
||||
|
||||
mock_context = Mock()
|
||||
mock_context.operation_name = "run"
|
||||
mock_context.operation_name = "run"
|
||||
mock_context.get_input.side_effect = Exception("Input error")
|
||||
mock_context.get_state.return_value = None
|
||||
|
||||
@@ -710,7 +725,7 @@ class TestIncomingRequestParsing:
|
||||
|
||||
request = Mock()
|
||||
request.params = {"thread_id": "query-thread"}
|
||||
req_body = {}
|
||||
req_body: dict[str, Any] = {}
|
||||
|
||||
thread_id = app._resolve_thread_id(request, req_body)
|
||||
|
||||
@@ -777,7 +792,7 @@ class TestHttpRunRoute:
|
||||
|
||||
assert run_request["message"] == "Plain text via HTTP"
|
||||
assert run_request["role"] == "user"
|
||||
assert "thread_id" in run_request
|
||||
assert "thread_id" not in run_request
|
||||
|
||||
async def test_http_run_accept_header_returns_json(self) -> None:
|
||||
"""Test that Accept header requesting JSON results in JSON response."""
|
||||
@@ -913,9 +928,9 @@ class TestMCPToolEndpoint:
|
||||
patch.object(app, "durable_client_input") as client_mock,
|
||||
):
|
||||
# Setup mock decorator chain
|
||||
func_name_mock.return_value = lambda f: f
|
||||
mcp_trigger_mock.return_value = lambda f: f
|
||||
client_mock.return_value = lambda f: f
|
||||
func_name_mock.return_value = _identity_decorator
|
||||
mcp_trigger_mock.return_value = _identity_decorator
|
||||
client_mock.return_value = _identity_decorator
|
||||
|
||||
app._setup_mcp_tool_trigger(mock_agent.name, mock_agent.description)
|
||||
|
||||
@@ -938,11 +953,11 @@ class TestMCPToolEndpoint:
|
||||
app = AgentFunctionApp()
|
||||
|
||||
with (
|
||||
patch.object(app, "function_name", return_value=lambda f: f),
|
||||
patch.object(app, "function_name", return_value=_identity_decorator),
|
||||
patch.object(app, "mcp_tool_trigger") as mcp_trigger_mock,
|
||||
patch.object(app, "durable_client_input", return_value=lambda f: f),
|
||||
patch.object(app, "durable_client_input", return_value=_identity_decorator),
|
||||
):
|
||||
mcp_trigger_mock.return_value = lambda f: f
|
||||
mcp_trigger_mock.return_value = _identity_decorator
|
||||
|
||||
app._setup_mcp_tool_trigger(mock_agent.name, None)
|
||||
|
||||
@@ -1128,10 +1143,10 @@ class TestMCPToolEndpoint:
|
||||
app = AgentFunctionApp(agents=[mock_agent], enable_mcp_tool_trigger=True)
|
||||
|
||||
# Capture the health check handler function
|
||||
captured_handler = None
|
||||
captured_handler: Callable[[func.HttpRequest], func.HttpResponse] | None = None
|
||||
|
||||
def capture_decorator(*args, **kwargs):
|
||||
def decorator(func):
|
||||
def capture_decorator(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]:
|
||||
def decorator(func: TFunc) -> TFunc:
|
||||
nonlocal captured_handler
|
||||
captured_handler = func
|
||||
return func
|
||||
|
||||
@@ -1,423 +1,32 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for AgentEntity and entity operations.
|
||||
"""Unit tests for create_agent_entity factory function.
|
||||
|
||||
Run with: pytest tests/test_entities.py -v
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from datetime import datetime
|
||||
from collections.abc import Callable
|
||||
from typing import Any, TypeVar
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from agent_framework import AgentResponse, AgentResponseUpdate, ChatMessage, Role
|
||||
from pydantic import BaseModel
|
||||
from agent_framework import AgentResponse, ChatMessage, Role
|
||||
|
||||
from agent_framework_azurefunctions._durable_agent_state import (
|
||||
DurableAgentState,
|
||||
DurableAgentStateData,
|
||||
DurableAgentStateMessage,
|
||||
DurableAgentStateRequest,
|
||||
DurableAgentStateTextContent,
|
||||
)
|
||||
from agent_framework_azurefunctions._entities import AgentEntity, create_agent_entity
|
||||
from agent_framework_azurefunctions._models import RunRequest
|
||||
from agent_framework_azurefunctions._entities import create_agent_entity
|
||||
|
||||
TFunc = TypeVar("TFunc", bound=Callable[..., Any])
|
||||
|
||||
|
||||
def _role_value(chat_message: DurableAgentStateMessage) -> str:
|
||||
"""Helper to extract the string role from a ChatMessage."""
|
||||
role = getattr(chat_message, "role", None)
|
||||
role_value = getattr(role, "value", role)
|
||||
if role_value is None:
|
||||
return ""
|
||||
return str(role_value)
|
||||
|
||||
|
||||
def _agent_response(text: str | None) -> AgentResponse:
|
||||
"""Create an AgentResponse with a single assistant message."""
|
||||
message = (
|
||||
ChatMessage(role="assistant", text=text) if text is not None else ChatMessage(role="assistant", contents=[])
|
||||
ChatMessage(role=Role.ASSISTANT, text=text)
|
||||
if text is not None
|
||||
else ChatMessage(role=Role.ASSISTANT, contents=[])
|
||||
)
|
||||
return AgentResponse(messages=[message])
|
||||
|
||||
|
||||
class RecordingCallback:
|
||||
"""Callback implementation capturing streaming and final responses for assertions."""
|
||||
|
||||
def __init__(self):
|
||||
self.stream_mock = AsyncMock()
|
||||
self.response_mock = AsyncMock()
|
||||
|
||||
async def on_streaming_response_update(
|
||||
self,
|
||||
update: AgentResponseUpdate,
|
||||
context: Any,
|
||||
) -> None:
|
||||
await self.stream_mock(update, context)
|
||||
|
||||
async def on_agent_response(self, response: AgentResponse, context: Any) -> None:
|
||||
await self.response_mock(response, context)
|
||||
|
||||
|
||||
class EntityStructuredResponse(BaseModel):
|
||||
answer: float
|
||||
|
||||
|
||||
class TestAgentEntityInit:
|
||||
"""Test suite for AgentEntity initialization."""
|
||||
|
||||
def test_init_creates_entity(self) -> None:
|
||||
"""Test that AgentEntity initializes correctly."""
|
||||
mock_agent = Mock()
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
|
||||
assert entity.agent == mock_agent
|
||||
assert len(entity.state.data.conversation_history) == 0
|
||||
assert entity.state.data.extension_data is None
|
||||
assert entity.state.schema_version == DurableAgentState.SCHEMA_VERSION
|
||||
|
||||
def test_init_stores_agent_reference(self) -> None:
|
||||
"""Test that the agent reference is stored correctly."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "TestAgent"
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
|
||||
assert entity.agent.name == "TestAgent"
|
||||
|
||||
def test_init_with_different_agent_types(self) -> None:
|
||||
"""Test initialization with different agent types."""
|
||||
agent1 = Mock()
|
||||
agent1.__class__.__name__ = "AzureOpenAIAgent"
|
||||
|
||||
agent2 = Mock()
|
||||
agent2.__class__.__name__ = "CustomAgent"
|
||||
|
||||
entity1 = AgentEntity(agent1)
|
||||
entity2 = AgentEntity(agent2)
|
||||
|
||||
assert entity1.agent.__class__.__name__ == "AzureOpenAIAgent"
|
||||
assert entity2.agent.__class__.__name__ == "CustomAgent"
|
||||
|
||||
|
||||
class TestAgentEntityRunAgent:
|
||||
"""Test suite for the run_agent operation."""
|
||||
|
||||
async def test_run_executes_agent(self) -> None:
|
||||
"""Test that run executes the agent."""
|
||||
mock_agent = Mock()
|
||||
mock_response = _agent_response("Test response")
|
||||
mock_agent.run = AsyncMock(return_value=mock_response)
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run(
|
||||
mock_context, {"message": "Test message", "thread_id": "conv-123", "correlationId": "corr-entity-1"}
|
||||
)
|
||||
|
||||
# Verify agent.run was called
|
||||
mock_agent.run.assert_called_once()
|
||||
_, kwargs = mock_agent.run.call_args
|
||||
sent_messages: list[Any] = kwargs.get("messages")
|
||||
assert len(sent_messages) == 1
|
||||
sent_message = sent_messages[0]
|
||||
assert isinstance(sent_message, ChatMessage)
|
||||
assert getattr(sent_message, "text", None) == "Test message"
|
||||
assert getattr(sent_message.role, "value", sent_message.role) == "user"
|
||||
|
||||
# Verify result
|
||||
assert isinstance(result, AgentResponse)
|
||||
assert result.text == "Test response"
|
||||
|
||||
async def test_run_agent_executes_agent(self) -> None:
|
||||
"""Test that run_agent executes the agent."""
|
||||
mock_agent = Mock()
|
||||
mock_response = _agent_response("Test response")
|
||||
mock_agent.run = AsyncMock(return_value=mock_response)
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run_agent(
|
||||
mock_context, {"message": "Test message", "thread_id": "conv-123", "correlationId": "corr-entity-1"}
|
||||
)
|
||||
|
||||
# Verify agent.run was called
|
||||
mock_agent.run.assert_called_once()
|
||||
_, kwargs = mock_agent.run.call_args
|
||||
sent_messages: list[Any] = kwargs.get("messages")
|
||||
assert len(sent_messages) == 1
|
||||
sent_message = sent_messages[0]
|
||||
assert isinstance(sent_message, ChatMessage)
|
||||
assert getattr(sent_message, "text", None) == "Test message"
|
||||
assert getattr(sent_message.role, "value", sent_message.role) == "user"
|
||||
|
||||
# Verify result
|
||||
assert isinstance(result, AgentResponse)
|
||||
assert result.text == "Test response"
|
||||
|
||||
async def test_run_agent_streaming_callbacks_invoked(self) -> None:
|
||||
"""Ensure streaming updates trigger callbacks and run() is not used."""
|
||||
|
||||
updates = [
|
||||
AgentResponseUpdate(text="Hello"),
|
||||
AgentResponseUpdate(text=" world"),
|
||||
]
|
||||
|
||||
async def update_generator() -> AsyncIterator[AgentResponseUpdate]:
|
||||
for update in updates:
|
||||
yield update
|
||||
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "StreamingAgent"
|
||||
mock_agent.run_stream = Mock(return_value=update_generator())
|
||||
mock_agent.run = AsyncMock(side_effect=AssertionError("run() should not be called when streaming succeeds"))
|
||||
|
||||
callback = RecordingCallback()
|
||||
entity = AgentEntity(mock_agent, callback=callback)
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run(
|
||||
mock_context,
|
||||
{
|
||||
"message": "Tell me something",
|
||||
"thread_id": "session-1",
|
||||
"correlationId": "corr-stream-1",
|
||||
},
|
||||
)
|
||||
|
||||
assert isinstance(result, AgentResponse)
|
||||
assert "Hello" in result.text
|
||||
assert callback.stream_mock.await_count == len(updates)
|
||||
assert callback.response_mock.await_count == 1
|
||||
mock_agent.run.assert_not_called()
|
||||
|
||||
# Validate callback arguments
|
||||
stream_calls = callback.stream_mock.await_args_list
|
||||
for expected_update, recorded_call in zip(updates, stream_calls, strict=True):
|
||||
assert recorded_call.args[0] is expected_update
|
||||
context = recorded_call.args[1]
|
||||
assert context.agent_name == "StreamingAgent"
|
||||
assert context.correlation_id == "corr-stream-1"
|
||||
assert context.thread_id == "session-1"
|
||||
assert context.request_message == "Tell me something"
|
||||
|
||||
final_call = callback.response_mock.await_args
|
||||
assert final_call is not None
|
||||
final_response, final_context = final_call.args
|
||||
assert final_context.agent_name == "StreamingAgent"
|
||||
assert final_context.correlation_id == "corr-stream-1"
|
||||
assert final_context.thread_id == "session-1"
|
||||
assert final_context.request_message == "Tell me something"
|
||||
assert getattr(final_response, "text", "").strip()
|
||||
|
||||
async def test_run_agent_final_callback_without_streaming(self) -> None:
|
||||
"""Ensure the final callback fires even when streaming is unavailable."""
|
||||
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "NonStreamingAgent"
|
||||
mock_agent.run_stream = None
|
||||
agent_response = _agent_response("Final response")
|
||||
mock_agent.run = AsyncMock(return_value=agent_response)
|
||||
|
||||
callback = RecordingCallback()
|
||||
entity = AgentEntity(mock_agent, callback=callback)
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run(
|
||||
mock_context,
|
||||
{
|
||||
"message": "Hi",
|
||||
"thread_id": "session-2",
|
||||
"correlationId": "corr-final-1",
|
||||
},
|
||||
)
|
||||
|
||||
assert isinstance(result, AgentResponse)
|
||||
assert result.text == "Final response"
|
||||
assert callback.stream_mock.await_count == 0
|
||||
assert callback.response_mock.await_count == 1
|
||||
|
||||
final_call = callback.response_mock.await_args
|
||||
assert final_call is not None
|
||||
assert final_call.args[0] is agent_response
|
||||
final_context = final_call.args[1]
|
||||
assert final_context.agent_name == "NonStreamingAgent"
|
||||
assert final_context.correlation_id == "corr-final-1"
|
||||
assert final_context.thread_id == "session-2"
|
||||
assert final_context.request_message == "Hi"
|
||||
|
||||
async def test_run_agent_updates_conversation_history(self) -> None:
|
||||
"""Test that run_agent updates the conversation history."""
|
||||
mock_agent = Mock()
|
||||
mock_response = _agent_response("Agent response")
|
||||
mock_agent.run = AsyncMock(return_value=mock_response)
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
await entity.run(
|
||||
mock_context, {"message": "User message", "thread_id": "conv-1", "correlationId": "corr-entity-2"}
|
||||
)
|
||||
|
||||
# Should have 1 entry: user message + assistant response
|
||||
user_history = entity.state.data.conversation_history[0].messages
|
||||
assistant_history = entity.state.data.conversation_history[1].messages
|
||||
|
||||
assert len(user_history) == 1
|
||||
|
||||
user_msg = user_history[0]
|
||||
assert _role_value(user_msg) == "user"
|
||||
assert user_msg.text == "User message"
|
||||
|
||||
assistant_msg = assistant_history[0]
|
||||
assert _role_value(assistant_msg) == "assistant"
|
||||
assert assistant_msg.text == "Agent response"
|
||||
|
||||
async def test_run_agent_increments_message_count(self) -> None:
|
||||
"""Test that run_agent increments the message count."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
assert len(entity.state.data.conversation_history) == 0
|
||||
|
||||
await entity.run(
|
||||
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-3a"}
|
||||
)
|
||||
assert len(entity.state.data.conversation_history) == 2
|
||||
|
||||
await entity.run(
|
||||
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-3b"}
|
||||
)
|
||||
assert len(entity.state.data.conversation_history) == 4
|
||||
|
||||
await entity.run(
|
||||
mock_context, {"message": "Message 3", "thread_id": "conv-1", "correlationId": "corr-entity-3c"}
|
||||
)
|
||||
assert len(entity.state.data.conversation_history) == 6
|
||||
|
||||
async def test_run_agent_with_none_thread_id(self) -> None:
|
||||
"""Test run_agent with a None thread identifier."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
with pytest.raises(ValueError, match="thread_id"):
|
||||
await entity.run(mock_context, {"message": "Message", "thread_id": None, "correlationId": "corr-entity-5"})
|
||||
|
||||
async def test_run_agent_multiple_conversations(self) -> None:
|
||||
"""Test that run_agent maintains history across multiple messages."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
# Send multiple messages
|
||||
await entity.run(
|
||||
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-8a"}
|
||||
)
|
||||
await entity.run(
|
||||
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-8b"}
|
||||
)
|
||||
await entity.run(
|
||||
mock_context, {"message": "Message 3", "thread_id": "conv-1", "correlationId": "corr-entity-8c"}
|
||||
)
|
||||
|
||||
history = entity.state.data.conversation_history
|
||||
assert len(history) == 6
|
||||
assert entity.state.message_count == 6
|
||||
|
||||
|
||||
class TestAgentEntityReset:
|
||||
"""Test suite for the reset operation."""
|
||||
|
||||
def test_reset_clears_conversation_history(self) -> None:
|
||||
"""Test that reset clears the conversation history."""
|
||||
mock_agent = Mock()
|
||||
entity = AgentEntity(mock_agent)
|
||||
|
||||
# Add some history with proper DurableAgentStateEntry objects
|
||||
entity.state.data.conversation_history = [
|
||||
DurableAgentStateRequest(
|
||||
correlation_id="test-1",
|
||||
created_at=datetime.now(),
|
||||
messages=[
|
||||
DurableAgentStateMessage(
|
||||
role="user",
|
||||
contents=[DurableAgentStateTextContent(text="msg1")],
|
||||
)
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
mock_context = Mock()
|
||||
entity.reset(mock_context)
|
||||
|
||||
assert entity.state.data.conversation_history == []
|
||||
|
||||
def test_reset_with_extension_data(self) -> None:
|
||||
"""Test that reset works when entity has extension data."""
|
||||
mock_agent = Mock()
|
||||
entity = AgentEntity(mock_agent)
|
||||
|
||||
# Set up some initial state with conversation history
|
||||
entity.state.data = DurableAgentStateData(conversation_history=[], extension_data={"some_key": "some_value"})
|
||||
|
||||
mock_context = Mock()
|
||||
entity.reset(mock_context)
|
||||
|
||||
assert len(entity.state.data.conversation_history) == 0
|
||||
|
||||
def test_reset_clears_message_count(self) -> None:
|
||||
"""Test that reset clears the message count."""
|
||||
mock_agent = Mock()
|
||||
entity = AgentEntity(mock_agent)
|
||||
|
||||
mock_context = Mock()
|
||||
entity.reset(mock_context)
|
||||
|
||||
assert len(entity.state.data.conversation_history) == 0
|
||||
|
||||
async def test_reset_after_conversation(self) -> None:
|
||||
"""Test reset after a full conversation."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
# Have a conversation
|
||||
await entity.run(
|
||||
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-10a"}
|
||||
)
|
||||
await entity.run(
|
||||
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-10b"}
|
||||
)
|
||||
|
||||
# Verify state before reset
|
||||
assert entity.state.message_count == 4
|
||||
assert len(entity.state.data.conversation_history) == 4
|
||||
|
||||
# Reset
|
||||
entity.reset(mock_context)
|
||||
|
||||
# Verify state after reset
|
||||
assert entity.state.message_count == 0
|
||||
assert len(entity.state.data.conversation_history) == 0
|
||||
|
||||
|
||||
class TestCreateAgentEntity:
|
||||
"""Test suite for the create_agent_entity factory function."""
|
||||
|
||||
@@ -439,9 +48,9 @@ class TestCreateAgentEntity:
|
||||
# Mock context
|
||||
mock_context = Mock()
|
||||
mock_context.operation_name = "run"
|
||||
mock_context.entity_key = "conv-123"
|
||||
mock_context.get_input.return_value = {
|
||||
"message": "Test message",
|
||||
"thread_id": "conv-123",
|
||||
"correlationId": "corr-entity-factory",
|
||||
}
|
||||
mock_context.get_state.return_value = None
|
||||
@@ -535,7 +144,7 @@ class TestCreateAgentEntity:
|
||||
assert state["data"] == {"conversationHistory": []}
|
||||
|
||||
def test_entity_function_restores_existing_state(self) -> None:
|
||||
"""Test that the entity function restores existing state."""
|
||||
"""Test that the entity function can operate when existing state is present."""
|
||||
mock_agent = Mock()
|
||||
|
||||
entity_function = create_agent_entity(mock_agent)
|
||||
@@ -584,482 +193,14 @@ class TestCreateAgentEntity:
|
||||
mock_context.operation_name = "reset"
|
||||
mock_context.get_state.return_value = existing_state
|
||||
|
||||
with patch.object(DurableAgentState, "from_dict", wraps=DurableAgentState.from_dict) as from_dict_mock:
|
||||
entity_function(mock_context)
|
||||
|
||||
from_dict_mock.assert_called_once_with(existing_state)
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
"""Test suite for error handling in entities."""
|
||||
|
||||
async def test_run_agent_handles_agent_exception(self) -> None:
|
||||
"""Test that run_agent handles agent exceptions."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(side_effect=Exception("Agent failed"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run(
|
||||
mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-error-1"}
|
||||
)
|
||||
|
||||
assert isinstance(result, AgentResponse)
|
||||
assert len(result.messages) == 1
|
||||
content = result.messages[0].contents[0]
|
||||
assert content.type == "error"
|
||||
assert "Agent failed" in (content.message or "")
|
||||
assert content.error_code == "Exception"
|
||||
|
||||
async def test_run_agent_handles_value_error(self) -> None:
|
||||
"""Test that run_agent handles ValueError instances."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(side_effect=ValueError("Invalid input"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run(
|
||||
mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-error-2"}
|
||||
)
|
||||
|
||||
assert isinstance(result, AgentResponse)
|
||||
assert len(result.messages) == 1
|
||||
content = result.messages[0].contents[0]
|
||||
assert content.type == "error"
|
||||
assert content.error_code == "ValueError"
|
||||
assert "Invalid input" in str(content.message)
|
||||
|
||||
async def test_run_agent_handles_timeout_error(self) -> None:
|
||||
"""Test that run_agent handles TimeoutError instances."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(side_effect=TimeoutError("Request timeout"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run(
|
||||
mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-error-3"}
|
||||
)
|
||||
|
||||
assert isinstance(result, AgentResponse)
|
||||
assert len(result.messages) == 1
|
||||
content = result.messages[0].contents[0]
|
||||
assert content.type == "error"
|
||||
assert content.error_code == "TimeoutError"
|
||||
|
||||
def test_entity_function_handles_exception_in_operation(self) -> None:
|
||||
"""Test that the entity function handles exceptions gracefully."""
|
||||
mock_agent = Mock()
|
||||
|
||||
entity_function = create_agent_entity(mock_agent)
|
||||
|
||||
mock_context = Mock()
|
||||
mock_context.operation_name = "run"
|
||||
mock_context.get_input.side_effect = Exception("Input error")
|
||||
mock_context.get_state.return_value = None
|
||||
|
||||
# Execute - should not raise
|
||||
entity_function(mock_context)
|
||||
|
||||
# Verify error was set
|
||||
assert mock_context.set_result.called
|
||||
result = mock_context.set_result.call_args[0][0]
|
||||
assert "error" in result
|
||||
|
||||
async def test_run_agent_preserves_message_on_error(self) -> None:
|
||||
"""Test that run_agent preserves message information on error."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(side_effect=Exception("Error"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run(
|
||||
mock_context,
|
||||
{"message": "Test message", "thread_id": "conv-123", "correlationId": "corr-entity-error-4"},
|
||||
)
|
||||
|
||||
# Even on error, message info should be preserved
|
||||
assert isinstance(result, AgentResponse)
|
||||
assert len(result.messages) == 1
|
||||
content = result.messages[0].contents[0]
|
||||
assert content.type == "error"
|
||||
|
||||
|
||||
class TestConversationHistory:
|
||||
"""Test suite for conversation history tracking."""
|
||||
|
||||
async def test_conversation_history_has_timestamps(self) -> None:
|
||||
"""Test that conversation history entries include timestamps."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
await entity.run(
|
||||
mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-history-1"}
|
||||
)
|
||||
|
||||
# Check both user and assistant messages have timestamps
|
||||
for entry in entity.state.data.conversation_history:
|
||||
timestamp = entry.created_at
|
||||
assert timestamp is not None
|
||||
# Verify timestamp is in ISO format
|
||||
datetime.fromisoformat(str(timestamp))
|
||||
|
||||
async def test_conversation_history_ordering(self) -> None:
|
||||
"""Test that conversation history maintains the correct order."""
|
||||
mock_agent = Mock()
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
# Send multiple messages with different responses
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response 1"))
|
||||
await entity.run(
|
||||
mock_context,
|
||||
{"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-history-2a"},
|
||||
)
|
||||
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response 2"))
|
||||
await entity.run(
|
||||
mock_context,
|
||||
{"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-history-2b"},
|
||||
)
|
||||
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response 3"))
|
||||
await entity.run(
|
||||
mock_context,
|
||||
{"message": "Message 3", "thread_id": "conv-1", "correlationId": "corr-entity-history-2c"},
|
||||
)
|
||||
|
||||
# Verify order
|
||||
history = entity.state.data.conversation_history
|
||||
# Each conversation turn creates 2 entries: request and response
|
||||
assert history[0].messages[0].text == "Message 1" # Request 1
|
||||
assert history[1].messages[0].text == "Response 1" # Response 1
|
||||
assert history[2].messages[0].text == "Message 2" # Request 2
|
||||
assert history[3].messages[0].text == "Response 2" # Response 2
|
||||
assert history[4].messages[0].text == "Message 3" # Request 3
|
||||
assert history[5].messages[0].text == "Response 3" # Response 3
|
||||
|
||||
async def test_conversation_history_role_alternation(self) -> None:
|
||||
"""Test that conversation history alternates between user and assistant roles."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
await entity.run(
|
||||
mock_context,
|
||||
{"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-history-3a"},
|
||||
)
|
||||
await entity.run(
|
||||
mock_context,
|
||||
{"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-history-3b"},
|
||||
)
|
||||
|
||||
# Check role alternation
|
||||
history = entity.state.data.conversation_history
|
||||
# Each conversation turn creates 2 entries: request and response
|
||||
assert history[0].messages[0].role == "user" # Request 1
|
||||
assert history[1].messages[0].role == "assistant" # Response 1
|
||||
assert history[2].messages[0].role == "user" # Request 2
|
||||
assert history[3].messages[0].role == "assistant" # Response 2
|
||||
|
||||
|
||||
class TestRunRequestSupport:
|
||||
"""Test suite for RunRequest support in entities."""
|
||||
|
||||
async def test_run_agent_with_run_request_object(self) -> None:
|
||||
"""Test run_agent with a RunRequest object."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
request = RunRequest(
|
||||
message="Test message",
|
||||
thread_id="conv-123",
|
||||
role=Role.USER,
|
||||
enable_tool_calls=True,
|
||||
correlation_id="corr-runreq-1",
|
||||
)
|
||||
|
||||
result = await entity.run(mock_context, request)
|
||||
|
||||
assert isinstance(result, AgentResponse)
|
||||
assert result.text == "Response"
|
||||
|
||||
async def test_run_agent_with_dict_request(self) -> None:
|
||||
"""Test run_agent with a dictionary request."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
request_dict = {
|
||||
"message": "Test message",
|
||||
"thread_id": "conv-456",
|
||||
"role": "system",
|
||||
"enable_tool_calls": False,
|
||||
"correlationId": "corr-runreq-2",
|
||||
}
|
||||
|
||||
result = await entity.run(mock_context, request_dict)
|
||||
|
||||
assert isinstance(result, AgentResponse)
|
||||
assert result.text == "Response"
|
||||
|
||||
async def test_run_agent_with_string_raises_without_correlation(self) -> None:
|
||||
"""Test that run_agent rejects legacy string input without correlation ID."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await entity.run(mock_context, "Simple message")
|
||||
|
||||
async def test_run_agent_stores_role_in_history(self) -> None:
|
||||
"""Test that run_agent stores the role in conversation history."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
# Send as system role
|
||||
request = RunRequest(
|
||||
message="System message",
|
||||
thread_id="conv-runreq-3",
|
||||
role=Role.SYSTEM,
|
||||
correlation_id="corr-runreq-3",
|
||||
)
|
||||
|
||||
await entity.run(mock_context, request)
|
||||
|
||||
# Check that system role was stored
|
||||
history = entity.state.data.conversation_history
|
||||
assert history[0].messages[0].role == "system"
|
||||
assert history[0].messages[0].text == "System message"
|
||||
|
||||
async def test_run_agent_with_response_format(self) -> None:
|
||||
"""Test run_agent with a JSON response format."""
|
||||
mock_agent = Mock()
|
||||
# Return JSON response
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response('{"answer": 42}'))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
request = RunRequest(
|
||||
message="What is the answer?",
|
||||
thread_id="conv-runreq-4",
|
||||
response_format=EntityStructuredResponse,
|
||||
correlation_id="corr-runreq-4",
|
||||
)
|
||||
|
||||
result = await entity.run(mock_context, request)
|
||||
|
||||
assert isinstance(result, AgentResponse)
|
||||
assert result.text == '{"answer": 42}'
|
||||
assert result.value is None
|
||||
|
||||
async def test_run_agent_disable_tool_calls(self) -> None:
|
||||
"""Test run_agent with tool calls disabled."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
request = RunRequest(
|
||||
message="Test", thread_id="conv-runreq-5", enable_tool_calls=False, correlation_id="corr-runreq-5"
|
||||
)
|
||||
|
||||
result = await entity.run(mock_context, request)
|
||||
|
||||
assert isinstance(result, AgentResponse)
|
||||
# Agent should have been called (tool disabling is framework-dependent)
|
||||
mock_agent.run.assert_called_once()
|
||||
|
||||
async def test_entity_function_with_run_request_dict(self) -> None:
|
||||
"""Test that the entity function handles the RunRequest dict format."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity_function = create_agent_entity(mock_agent)
|
||||
|
||||
mock_context = Mock()
|
||||
mock_context.operation_name = "run"
|
||||
mock_context.get_input.return_value = {
|
||||
"message": "Test message",
|
||||
"thread_id": "conv-789",
|
||||
"role": "user",
|
||||
"enable_tool_calls": True,
|
||||
"correlationId": "corr-runreq-6",
|
||||
}
|
||||
mock_context.get_state.return_value = None
|
||||
|
||||
await asyncio.to_thread(entity_function, mock_context)
|
||||
|
||||
# Verify result was set
|
||||
assert mock_context.set_result.called
|
||||
result = mock_context.set_result.call_args[0][0]
|
||||
assert isinstance(result, dict)
|
||||
|
||||
# Check if messages are present
|
||||
assert "messages" in result
|
||||
assert len(result["messages"]) > 0
|
||||
message = result["messages"][0]
|
||||
|
||||
# Check for text in various possible locations
|
||||
text_found = False
|
||||
if "text" in message and message["text"] == "Response":
|
||||
text_found = True
|
||||
elif "contents" in message:
|
||||
for content in message["contents"]:
|
||||
if isinstance(content, dict) and content.get("text") == "Response":
|
||||
text_found = True
|
||||
break
|
||||
|
||||
assert text_found, f"Response text not found in message: {message}"
|
||||
|
||||
|
||||
class TestDurableAgentStateRequestOrchestrationId:
|
||||
"""Test suite for DurableAgentStateRequest orchestration_id field."""
|
||||
|
||||
def test_request_with_orchestration_id(self) -> None:
|
||||
"""Test creating a request with an orchestration_id."""
|
||||
request = DurableAgentStateRequest(
|
||||
correlation_id="corr-123",
|
||||
created_at=datetime.now(),
|
||||
messages=[
|
||||
DurableAgentStateMessage(
|
||||
role="user",
|
||||
contents=[DurableAgentStateTextContent(text="test")],
|
||||
)
|
||||
],
|
||||
orchestration_id="orch-456",
|
||||
)
|
||||
|
||||
assert request.orchestration_id == "orch-456"
|
||||
|
||||
def test_request_to_dict_includes_orchestration_id(self) -> None:
|
||||
"""Test that to_dict includes orchestrationId when set."""
|
||||
request = DurableAgentStateRequest(
|
||||
correlation_id="corr-123",
|
||||
created_at=datetime.now(),
|
||||
messages=[
|
||||
DurableAgentStateMessage(
|
||||
role="user",
|
||||
contents=[DurableAgentStateTextContent(text="test")],
|
||||
)
|
||||
],
|
||||
orchestration_id="orch-789",
|
||||
)
|
||||
|
||||
data = request.to_dict()
|
||||
|
||||
assert "orchestrationId" in data
|
||||
assert data["orchestrationId"] == "orch-789"
|
||||
|
||||
def test_request_to_dict_excludes_orchestration_id_when_none(self) -> None:
|
||||
"""Test that to_dict excludes orchestrationId when not set."""
|
||||
request = DurableAgentStateRequest(
|
||||
correlation_id="corr-123",
|
||||
created_at=datetime.now(),
|
||||
messages=[
|
||||
DurableAgentStateMessage(
|
||||
role="user",
|
||||
contents=[DurableAgentStateTextContent(text="test")],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
data = request.to_dict()
|
||||
|
||||
assert "orchestrationId" not in data
|
||||
|
||||
def test_request_from_dict_with_orchestration_id(self) -> None:
|
||||
"""Test from_dict correctly parses orchestrationId."""
|
||||
data = {
|
||||
"$type": "request",
|
||||
"correlationId": "corr-123",
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"messages": [{"role": "user", "contents": [{"$type": "text", "text": "test"}]}],
|
||||
"orchestrationId": "orch-from-dict",
|
||||
}
|
||||
|
||||
request = DurableAgentStateRequest.from_dict(data)
|
||||
|
||||
assert request.orchestration_id == "orch-from-dict"
|
||||
|
||||
def test_request_from_run_request_with_orchestration_id(self) -> None:
|
||||
"""Test from_run_request correctly transfers orchestration_id."""
|
||||
run_request = RunRequest(
|
||||
message="test message",
|
||||
correlation_id="corr-run",
|
||||
orchestration_id="orch-from-run-request",
|
||||
)
|
||||
|
||||
durable_request = DurableAgentStateRequest.from_run_request(run_request)
|
||||
|
||||
assert durable_request.orchestration_id == "orch-from-run-request"
|
||||
|
||||
def test_request_from_run_request_without_orchestration_id(self) -> None:
|
||||
"""Test from_run_request correctly handles missing orchestration_id."""
|
||||
run_request = RunRequest(
|
||||
message="test message",
|
||||
correlation_id="corr-run",
|
||||
)
|
||||
|
||||
durable_request = DurableAgentStateRequest.from_run_request(run_request)
|
||||
|
||||
assert durable_request.orchestration_id is None
|
||||
|
||||
|
||||
class TestDurableAgentStateMessageCreatedAt:
|
||||
"""Test suite for DurableAgentStateMessage created_at field handling."""
|
||||
|
||||
def test_message_from_run_request_without_created_at_preserves_none(self) -> None:
|
||||
"""Test from_run_request preserves None created_at instead of defaulting to current time.
|
||||
|
||||
When a RunRequest has no created_at value, the resulting DurableAgentStateMessage
|
||||
should also have None for created_at, not default to current UTC time.
|
||||
"""
|
||||
run_request = RunRequest(
|
||||
message="test message",
|
||||
correlation_id="corr-run",
|
||||
created_at=None, # Explicitly None
|
||||
)
|
||||
|
||||
durable_message = DurableAgentStateMessage.from_run_request(run_request)
|
||||
|
||||
assert durable_message.created_at is None
|
||||
|
||||
def test_message_from_run_request_with_created_at_parses_correctly(self) -> None:
|
||||
"""Test from_run_request correctly parses a valid created_at timestamp."""
|
||||
run_request = RunRequest(
|
||||
message="test message",
|
||||
correlation_id="corr-run",
|
||||
created_at="2024-01-15T10:30:00Z",
|
||||
)
|
||||
|
||||
durable_message = DurableAgentStateMessage.from_run_request(run_request)
|
||||
|
||||
assert durable_message.created_at is not None
|
||||
assert durable_message.created_at.year == 2024
|
||||
assert durable_message.created_at.month == 1
|
||||
assert durable_message.created_at.day == 15
|
||||
# Reset should clear history and persist via set_state
|
||||
assert mock_context.set_state.called
|
||||
persisted_state = mock_context.set_state.call_args[0][0]
|
||||
assert persisted_state["data"]["conversationHistory"] == []
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,447 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for data models (AgentSessionId, RunRequest, AgentResponse)."""
|
||||
|
||||
import azure.durable_functions as df
|
||||
import pytest
|
||||
from agent_framework import Role
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework_azurefunctions._models import AgentSessionId, RunRequest
|
||||
|
||||
|
||||
class ModuleStructuredResponse(BaseModel):
|
||||
value: int
|
||||
|
||||
|
||||
class TestAgentSessionId:
|
||||
"""Test suite for AgentSessionId."""
|
||||
|
||||
def test_init_creates_session_id(self) -> None:
|
||||
"""Test that AgentSessionId initializes correctly."""
|
||||
session_id = AgentSessionId(name="AgentEntity", key="test-key-123")
|
||||
|
||||
assert session_id.name == "AgentEntity"
|
||||
assert session_id.key == "test-key-123"
|
||||
|
||||
def test_with_random_key_generates_guid(self) -> None:
|
||||
"""Test that with_random_key generates a GUID."""
|
||||
session_id = AgentSessionId.with_random_key(name="AgentEntity")
|
||||
|
||||
assert session_id.name == "AgentEntity"
|
||||
assert len(session_id.key) == 32 # UUID hex is 32 chars
|
||||
# Verify it's a valid hex string
|
||||
int(session_id.key, 16)
|
||||
|
||||
def test_with_random_key_unique_keys(self) -> None:
|
||||
"""Test that with_random_key generates unique keys."""
|
||||
session_id1 = AgentSessionId.with_random_key(name="AgentEntity")
|
||||
session_id2 = AgentSessionId.with_random_key(name="AgentEntity")
|
||||
|
||||
assert session_id1.key != session_id2.key
|
||||
|
||||
def test_to_entity_id_conversion(self) -> None:
|
||||
"""Test conversion to EntityId."""
|
||||
session_id = AgentSessionId(name="AgentEntity", key="test-key")
|
||||
entity_id = session_id.to_entity_id()
|
||||
|
||||
assert isinstance(entity_id, df.EntityId)
|
||||
assert entity_id.name == "dafx-AgentEntity"
|
||||
assert entity_id.key == "test-key"
|
||||
|
||||
def test_from_entity_id_conversion(self) -> None:
|
||||
"""Test creation from EntityId."""
|
||||
entity_id = df.EntityId(name="dafx-AgentEntity", key="test-key")
|
||||
session_id = AgentSessionId.from_entity_id(entity_id)
|
||||
|
||||
assert isinstance(session_id, AgentSessionId)
|
||||
assert session_id.name == "AgentEntity"
|
||||
assert session_id.key == "test-key"
|
||||
|
||||
def test_round_trip_entity_id_conversion(self) -> None:
|
||||
"""Test round-trip conversion to and from EntityId."""
|
||||
original = AgentSessionId(name="AgentEntity", key="test-key")
|
||||
entity_id = original.to_entity_id()
|
||||
restored = AgentSessionId.from_entity_id(entity_id)
|
||||
|
||||
assert restored.name == original.name
|
||||
assert restored.key == original.key
|
||||
|
||||
def test_str_representation(self) -> None:
|
||||
"""Test string representation."""
|
||||
session_id = AgentSessionId(name="AgentEntity", key="test-key-123")
|
||||
str_repr = str(session_id)
|
||||
|
||||
assert str_repr == "@AgentEntity@test-key-123"
|
||||
|
||||
def test_repr_representation(self) -> None:
|
||||
"""Test repr representation."""
|
||||
session_id = AgentSessionId(name="AgentEntity", key="test-key")
|
||||
repr_str = repr(session_id)
|
||||
|
||||
assert "AgentSessionId" in repr_str
|
||||
assert "AgentEntity" in repr_str
|
||||
assert "test-key" in repr_str
|
||||
|
||||
def test_parse_valid_session_id(self) -> None:
|
||||
"""Test parsing valid session ID string."""
|
||||
session_id = AgentSessionId.parse("@AgentEntity@test-key-123")
|
||||
|
||||
assert session_id.name == "AgentEntity"
|
||||
assert session_id.key == "test-key-123"
|
||||
|
||||
def test_parse_invalid_format_no_prefix(self) -> None:
|
||||
"""Test parsing invalid format without @ prefix."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
AgentSessionId.parse("AgentEntity@test-key")
|
||||
|
||||
assert "Invalid agent session ID format" in str(exc_info.value)
|
||||
|
||||
def test_parse_invalid_format_single_part(self) -> None:
|
||||
"""Test parsing invalid format with single part."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
AgentSessionId.parse("@AgentEntity")
|
||||
|
||||
assert "Invalid agent session ID format" in str(exc_info.value)
|
||||
|
||||
def test_parse_with_multiple_at_signs_in_key(self) -> None:
|
||||
"""Test parsing with @ signs in the key."""
|
||||
session_id = AgentSessionId.parse("@AgentEntity@key-with@symbols")
|
||||
|
||||
assert session_id.name == "AgentEntity"
|
||||
assert session_id.key == "key-with@symbols"
|
||||
|
||||
def test_parse_round_trip(self) -> None:
|
||||
"""Test round-trip parse and string conversion."""
|
||||
original = AgentSessionId(name="AgentEntity", key="test-key")
|
||||
str_repr = str(original)
|
||||
parsed = AgentSessionId.parse(str_repr)
|
||||
|
||||
assert parsed.name == original.name
|
||||
assert parsed.key == original.key
|
||||
|
||||
def test_parse_with_agent_name_override(self) -> None:
|
||||
"""Test parsing @name@key format with agent_name parameter overrides the name."""
|
||||
session_id = AgentSessionId.parse("@OriginalAgent@test-key-123", agent_name="OverriddenAgent")
|
||||
|
||||
assert session_id.name == "OverriddenAgent"
|
||||
assert session_id.key == "test-key-123"
|
||||
|
||||
def test_parse_without_agent_name_uses_parsed_name(self) -> None:
|
||||
"""Test parsing @name@key format without agent_name uses name from string."""
|
||||
session_id = AgentSessionId.parse("@ParsedAgent@test-key-123")
|
||||
|
||||
assert session_id.name == "ParsedAgent"
|
||||
assert session_id.key == "test-key-123"
|
||||
|
||||
def test_parse_plain_string_with_agent_name(self) -> None:
|
||||
"""Test parsing plain string with agent_name uses entire string as key."""
|
||||
session_id = AgentSessionId.parse("simple-thread-123", agent_name="TestAgent")
|
||||
|
||||
assert session_id.name == "TestAgent"
|
||||
assert session_id.key == "simple-thread-123"
|
||||
|
||||
def test_parse_plain_string_without_agent_name_raises(self) -> None:
|
||||
"""Test parsing plain string without agent_name raises ValueError."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
AgentSessionId.parse("simple-thread-123")
|
||||
|
||||
assert "Invalid agent session ID format" in str(exc_info.value)
|
||||
|
||||
def test_to_entity_name_adds_prefix(self) -> None:
|
||||
"""Test that to_entity_name adds the dafx- prefix."""
|
||||
entity_name = AgentSessionId.to_entity_name("TestAgent")
|
||||
assert entity_name == "dafx-TestAgent"
|
||||
|
||||
def test_from_entity_id_strips_prefix(self) -> None:
|
||||
"""Test that from_entity_id strips the dafx- prefix."""
|
||||
entity_id = df.EntityId(name="dafx-TestAgent", key="key123")
|
||||
session_id = AgentSessionId.from_entity_id(entity_id)
|
||||
|
||||
assert session_id.name == "TestAgent"
|
||||
assert session_id.key == "key123"
|
||||
|
||||
def test_from_entity_id_raises_without_prefix(self) -> None:
|
||||
"""Test that from_entity_id raises ValueError when entity name lacks the prefix."""
|
||||
entity_id = df.EntityId(name="TestAgent", key="key123")
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
AgentSessionId.from_entity_id(entity_id)
|
||||
|
||||
assert "not a valid agent session ID" in str(exc_info.value)
|
||||
assert "dafx-" in str(exc_info.value)
|
||||
|
||||
|
||||
class TestRunRequest:
|
||||
"""Test suite for RunRequest."""
|
||||
|
||||
def test_init_with_defaults(self) -> None:
|
||||
"""Test RunRequest initialization with defaults."""
|
||||
request = RunRequest(message="Hello", thread_id="thread-default")
|
||||
|
||||
assert request.message == "Hello"
|
||||
assert request.role == Role.USER
|
||||
assert request.response_format is None
|
||||
assert request.enable_tool_calls is True
|
||||
assert request.thread_id == "thread-default"
|
||||
|
||||
def test_init_with_all_fields(self) -> None:
|
||||
"""Test RunRequest initialization with all fields."""
|
||||
schema = ModuleStructuredResponse
|
||||
request = RunRequest(
|
||||
message="Hello",
|
||||
thread_id="thread-123",
|
||||
role=Role.SYSTEM,
|
||||
response_format=schema,
|
||||
enable_tool_calls=False,
|
||||
)
|
||||
|
||||
assert request.message == "Hello"
|
||||
assert request.role == Role.SYSTEM
|
||||
assert request.response_format is schema
|
||||
assert request.enable_tool_calls is False
|
||||
assert request.thread_id == "thread-123"
|
||||
|
||||
def test_init_coerces_string_role(self) -> None:
|
||||
"""Ensure string role values are coerced into Role instances."""
|
||||
request = RunRequest(message="Hello", thread_id="thread-str-role", role="system") # type: ignore[arg-type]
|
||||
|
||||
assert request.role == Role.SYSTEM
|
||||
|
||||
def test_to_dict_with_defaults(self) -> None:
|
||||
"""Test to_dict with default values."""
|
||||
request = RunRequest(message="Test message", thread_id="thread-to-dict")
|
||||
data = request.to_dict()
|
||||
|
||||
assert data["message"] == "Test message"
|
||||
assert data["enable_tool_calls"] is True
|
||||
assert data["role"] == "user"
|
||||
assert "response_format" not in data or data["response_format"] is None
|
||||
assert data["thread_id"] == "thread-to-dict"
|
||||
|
||||
def test_to_dict_with_all_fields(self) -> None:
|
||||
"""Test to_dict with all fields."""
|
||||
schema = ModuleStructuredResponse
|
||||
request = RunRequest(
|
||||
message="Hello",
|
||||
thread_id="thread-456",
|
||||
role=Role.ASSISTANT,
|
||||
response_format=schema,
|
||||
enable_tool_calls=False,
|
||||
)
|
||||
data = request.to_dict()
|
||||
|
||||
assert data["message"] == "Hello"
|
||||
assert data["role"] == "assistant"
|
||||
assert data["response_format"]["__response_schema_type__"] == "pydantic_model"
|
||||
assert data["response_format"]["module"] == schema.__module__
|
||||
assert data["response_format"]["qualname"] == schema.__qualname__
|
||||
assert data["enable_tool_calls"] is False
|
||||
assert data["thread_id"] == "thread-456"
|
||||
|
||||
def test_from_dict_with_defaults(self) -> None:
|
||||
"""Test from_dict with minimal data."""
|
||||
data = {"message": "Hello", "thread_id": "thread-from-dict"}
|
||||
request = RunRequest.from_dict(data)
|
||||
|
||||
assert request.message == "Hello"
|
||||
assert request.role == Role.USER
|
||||
assert request.enable_tool_calls is True
|
||||
assert request.thread_id == "thread-from-dict"
|
||||
|
||||
def test_from_dict_with_all_fields(self) -> None:
|
||||
"""Test from_dict with all fields."""
|
||||
data = {
|
||||
"message": "Test",
|
||||
"role": "system",
|
||||
"response_format": {
|
||||
"__response_schema_type__": "pydantic_model",
|
||||
"module": ModuleStructuredResponse.__module__,
|
||||
"qualname": ModuleStructuredResponse.__qualname__,
|
||||
},
|
||||
"enable_tool_calls": False,
|
||||
"thread_id": "thread-789",
|
||||
}
|
||||
request = RunRequest.from_dict(data)
|
||||
|
||||
assert request.message == "Test"
|
||||
assert request.role == Role.SYSTEM
|
||||
assert request.response_format is ModuleStructuredResponse
|
||||
assert request.enable_tool_calls is False
|
||||
assert request.thread_id == "thread-789"
|
||||
|
||||
def test_from_dict_with_unknown_role_preserves_value(self) -> None:
|
||||
"""Test from_dict keeps custom roles intact."""
|
||||
data = {"message": "Test", "role": "reviewer", "thread_id": "thread-with-custom-role"}
|
||||
request = RunRequest.from_dict(data)
|
||||
|
||||
assert request.role.value == "reviewer"
|
||||
assert request.role != Role.USER
|
||||
|
||||
def test_from_dict_empty_message(self) -> None:
|
||||
"""Test from_dict with empty message."""
|
||||
data = {"thread_id": "thread-empty"}
|
||||
request = RunRequest.from_dict(data)
|
||||
|
||||
assert request.message == ""
|
||||
assert request.role == Role.USER
|
||||
assert request.thread_id == "thread-empty"
|
||||
|
||||
def test_round_trip_dict_conversion(self) -> None:
|
||||
"""Test round-trip to_dict and from_dict."""
|
||||
original = RunRequest(
|
||||
message="Test message",
|
||||
thread_id="thread-123",
|
||||
role=Role.SYSTEM,
|
||||
response_format=ModuleStructuredResponse,
|
||||
enable_tool_calls=False,
|
||||
)
|
||||
|
||||
data = original.to_dict()
|
||||
restored = RunRequest.from_dict(data)
|
||||
|
||||
assert restored.message == original.message
|
||||
assert restored.role == original.role
|
||||
assert restored.response_format is ModuleStructuredResponse
|
||||
assert restored.enable_tool_calls == original.enable_tool_calls
|
||||
assert restored.thread_id == original.thread_id
|
||||
|
||||
def test_round_trip_with_pydantic_response_format(self) -> None:
|
||||
"""Ensure Pydantic response formats serialize and deserialize properly."""
|
||||
original = RunRequest(
|
||||
message="Structured",
|
||||
thread_id="thread-pydantic",
|
||||
response_format=ModuleStructuredResponse,
|
||||
)
|
||||
|
||||
data = original.to_dict()
|
||||
|
||||
assert data["response_format"]["__response_schema_type__"] == "pydantic_model"
|
||||
assert data["response_format"]["module"] == ModuleStructuredResponse.__module__
|
||||
assert data["response_format"]["qualname"] == ModuleStructuredResponse.__qualname__
|
||||
|
||||
restored = RunRequest.from_dict(data)
|
||||
assert restored.response_format is ModuleStructuredResponse
|
||||
|
||||
def test_init_with_correlationId(self) -> None:
|
||||
"""Test RunRequest initialization with correlationId."""
|
||||
request = RunRequest(message="Test message", thread_id="thread-corr-init", correlation_id="corr-123")
|
||||
|
||||
assert request.message == "Test message"
|
||||
assert request.correlation_id == "corr-123"
|
||||
|
||||
def test_to_dict_with_correlationId(self) -> None:
|
||||
"""Test to_dict includes correlationId."""
|
||||
request = RunRequest(message="Test", thread_id="thread-corr-to-dict", correlation_id="corr-456")
|
||||
data = request.to_dict()
|
||||
|
||||
assert data["message"] == "Test"
|
||||
assert data["correlationId"] == "corr-456"
|
||||
|
||||
def test_from_dict_with_correlationId(self) -> None:
|
||||
"""Test from_dict with correlationId."""
|
||||
data = {"message": "Test", "correlationId": "corr-789", "thread_id": "thread-corr-from-dict"}
|
||||
request = RunRequest.from_dict(data)
|
||||
|
||||
assert request.message == "Test"
|
||||
assert request.correlation_id == "corr-789"
|
||||
assert request.thread_id == "thread-corr-from-dict"
|
||||
|
||||
def test_round_trip_with_correlationId(self) -> None:
|
||||
"""Test round-trip to_dict and from_dict with correlationId."""
|
||||
original = RunRequest(
|
||||
message="Test message",
|
||||
thread_id="thread-123",
|
||||
role=Role.SYSTEM,
|
||||
correlation_id="corr-123",
|
||||
)
|
||||
|
||||
data = original.to_dict()
|
||||
restored = RunRequest.from_dict(data)
|
||||
|
||||
assert restored.message == original.message
|
||||
assert restored.role == original.role
|
||||
assert restored.correlation_id == original.correlation_id
|
||||
assert restored.thread_id == original.thread_id
|
||||
|
||||
def test_init_with_orchestration_id(self) -> None:
|
||||
"""Test RunRequest initialization with orchestration_id."""
|
||||
request = RunRequest(
|
||||
message="Test message",
|
||||
thread_id="thread-orch-init",
|
||||
orchestration_id="orch-123",
|
||||
)
|
||||
|
||||
assert request.message == "Test message"
|
||||
assert request.orchestration_id == "orch-123"
|
||||
|
||||
def test_to_dict_with_orchestration_id(self) -> None:
|
||||
"""Test to_dict includes orchestrationId."""
|
||||
request = RunRequest(
|
||||
message="Test",
|
||||
thread_id="thread-orch-to-dict",
|
||||
orchestration_id="orch-456",
|
||||
)
|
||||
data = request.to_dict()
|
||||
|
||||
assert data["message"] == "Test"
|
||||
assert data["orchestrationId"] == "orch-456"
|
||||
|
||||
def test_to_dict_excludes_orchestration_id_when_none(self) -> None:
|
||||
"""Test to_dict excludes orchestrationId when not set."""
|
||||
request = RunRequest(
|
||||
message="Test",
|
||||
thread_id="thread-orch-none",
|
||||
)
|
||||
data = request.to_dict()
|
||||
|
||||
assert "orchestrationId" not in data
|
||||
|
||||
def test_from_dict_with_orchestration_id(self) -> None:
|
||||
"""Test from_dict with orchestrationId."""
|
||||
data = {
|
||||
"message": "Test",
|
||||
"orchestrationId": "orch-789",
|
||||
"thread_id": "thread-orch-from-dict",
|
||||
}
|
||||
request = RunRequest.from_dict(data)
|
||||
|
||||
assert request.message == "Test"
|
||||
assert request.orchestration_id == "orch-789"
|
||||
assert request.thread_id == "thread-orch-from-dict"
|
||||
|
||||
def test_round_trip_with_orchestration_id(self) -> None:
|
||||
"""Test round-trip to_dict and from_dict with orchestration_id."""
|
||||
original = RunRequest(
|
||||
message="Test message",
|
||||
thread_id="thread-123",
|
||||
role=Role.SYSTEM,
|
||||
correlation_id="corr-123",
|
||||
orchestration_id="orch-123",
|
||||
)
|
||||
|
||||
data = original.to_dict()
|
||||
restored = RunRequest.from_dict(data)
|
||||
|
||||
assert restored.message == original.message
|
||||
assert restored.role == original.role
|
||||
assert restored.correlation_id == original.correlation_id
|
||||
assert restored.orchestration_id == original.orchestration_id
|
||||
assert restored.thread_id == original.thread_id
|
||||
|
||||
|
||||
class TestModelIntegration:
|
||||
"""Test suite for integration between models."""
|
||||
|
||||
def test_run_request_with_session_id(self) -> None:
|
||||
"""Test using RunRequest with AgentSessionId."""
|
||||
session_id = AgentSessionId.with_random_key("AgentEntity")
|
||||
request = RunRequest(message="Test message", thread_id=str(session_id))
|
||||
|
||||
assert request.thread_id is not None
|
||||
assert request.thread_id == str(session_id)
|
||||
assert request.thread_id.startswith("@AgentEntity@")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
@@ -6,11 +6,11 @@ from typing import Any
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from agent_framework import AgentResponse, AgentThread, ChatMessage
|
||||
from agent_framework import AgentResponse, ChatMessage, Role
|
||||
from agent_framework_durabletask import DurableAIAgent
|
||||
from azure.durable_functions.models.Task import TaskBase, TaskState
|
||||
|
||||
from agent_framework_azurefunctions import AgentFunctionApp, DurableAIAgent
|
||||
from agent_framework_azurefunctions._models import AgentSessionId, DurableAgentThread
|
||||
from agent_framework_azurefunctions import AgentFunctionApp
|
||||
from agent_framework_azurefunctions._orchestration import AgentTask
|
||||
|
||||
|
||||
@@ -38,46 +38,96 @@ def _create_entity_task(task_id: int = 1) -> TaskBase:
|
||||
return _FakeTask(task_id)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_context():
|
||||
"""Create a mock orchestration context with UUID support."""
|
||||
context = Mock()
|
||||
context.instance_id = "test-instance"
|
||||
context.current_utc_datetime = Mock()
|
||||
return context
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_context_with_uuid() -> tuple[Mock, str]:
|
||||
"""Create a mock context with a single UUID."""
|
||||
from uuid import UUID
|
||||
|
||||
context = Mock()
|
||||
context.instance_id = "test-instance"
|
||||
context.current_utc_datetime = Mock()
|
||||
test_uuid = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
context.new_uuid = Mock(return_value=test_uuid)
|
||||
return context, test_uuid.hex
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_context_with_multiple_uuids() -> tuple[Mock, list[str]]:
|
||||
"""Create a mock context with multiple UUIDs via side_effect."""
|
||||
from uuid import UUID
|
||||
|
||||
context = Mock()
|
||||
context.instance_id = "test-instance"
|
||||
context.current_utc_datetime = Mock()
|
||||
uuids = [
|
||||
UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
|
||||
UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
|
||||
UUID("cccccccc-cccc-cccc-cccc-cccccccccccc"),
|
||||
]
|
||||
context.new_uuid = Mock(side_effect=uuids)
|
||||
# Return the hex versions for assertion checking
|
||||
hex_uuids = [uuid.hex for uuid in uuids]
|
||||
return context, hex_uuids
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def executor_with_uuid() -> tuple[Any, Mock, str]:
|
||||
"""Create an executor with a mocked generate_unique_id method."""
|
||||
from agent_framework_azurefunctions._orchestration import AzureFunctionsAgentExecutor
|
||||
|
||||
context = Mock()
|
||||
context.instance_id = "test-instance"
|
||||
context.current_utc_datetime = Mock()
|
||||
|
||||
executor = AzureFunctionsAgentExecutor(context)
|
||||
test_uuid_hex = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
executor.generate_unique_id = Mock(return_value=test_uuid_hex)
|
||||
|
||||
return executor, context, test_uuid_hex
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def executor_with_multiple_uuids() -> tuple[Any, Mock, list[str]]:
|
||||
"""Create an executor with multiple mocked UUIDs."""
|
||||
from agent_framework_azurefunctions._orchestration import AzureFunctionsAgentExecutor
|
||||
|
||||
context = Mock()
|
||||
context.instance_id = "test-instance"
|
||||
context.current_utc_datetime = Mock()
|
||||
|
||||
executor = AzureFunctionsAgentExecutor(context)
|
||||
uuid_hexes = [
|
||||
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
"cccccccc-cccc-cccc-cccc-cccccccccccc",
|
||||
"dddddddd-dddd-dddd-dddd-dddddddddddd",
|
||||
"eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee",
|
||||
]
|
||||
executor.generate_unique_id = Mock(side_effect=uuid_hexes)
|
||||
|
||||
return executor, context, uuid_hexes
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def executor_with_context(mock_context_with_uuid: tuple[Mock, str]) -> tuple[Any, Mock]:
|
||||
"""Create an executor with a mocked context."""
|
||||
from agent_framework_azurefunctions._orchestration import AzureFunctionsAgentExecutor
|
||||
|
||||
context, _ = mock_context_with_uuid
|
||||
return AzureFunctionsAgentExecutor(context), context
|
||||
|
||||
|
||||
class TestAgentResponseHelpers:
|
||||
"""Tests for helper utilities that prepare AgentResponse values."""
|
||||
|
||||
@staticmethod
|
||||
def _create_agent_task() -> AgentTask:
|
||||
entity_task = _create_entity_task()
|
||||
return AgentTask(entity_task, None, "correlation-id")
|
||||
|
||||
def test_load_agent_response_from_instance(self) -> None:
|
||||
task = self._create_agent_task()
|
||||
response = AgentResponse(messages=[ChatMessage(role="assistant", text='{"foo": "bar"}')])
|
||||
|
||||
loaded = task._load_agent_response(response)
|
||||
|
||||
assert loaded is response
|
||||
assert loaded.value is None
|
||||
|
||||
def test_load_agent_response_from_serialized(self) -> None:
|
||||
task = self._create_agent_task()
|
||||
serialized = AgentResponse(messages=[ChatMessage(role="assistant", text="structured")]).to_dict()
|
||||
serialized["value"] = {"answer": 42}
|
||||
|
||||
loaded = task._load_agent_response(serialized)
|
||||
|
||||
assert loaded is not None
|
||||
assert loaded.value == {"answer": 42}
|
||||
loaded_dict = loaded.to_dict()
|
||||
assert loaded_dict["type"] == "agent_response"
|
||||
|
||||
def test_load_agent_response_rejects_none(self) -> None:
|
||||
task = self._create_agent_task()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
task._load_agent_response(None)
|
||||
|
||||
def test_load_agent_response_rejects_unsupported_type(self) -> None:
|
||||
task = self._create_agent_task()
|
||||
|
||||
with pytest.raises(TypeError, match="Unsupported type"):
|
||||
task._load_agent_response(["invalid", "list"]) # type: ignore[arg-type]
|
||||
"""Tests for response handling through public AgentTask API."""
|
||||
|
||||
def test_try_set_value_success(self) -> None:
|
||||
"""Test try_set_value correctly processes successful task completion."""
|
||||
@@ -142,334 +192,10 @@ class TestAgentResponseHelpers:
|
||||
assert isinstance(task.result.value, TestSchema)
|
||||
assert task.result.value.answer == "42"
|
||||
|
||||
def test_ensure_response_format_parses_value(self) -> None:
|
||||
"""Test _ensure_response_format correctly parses response value."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
class SampleSchema(BaseModel):
|
||||
name: str
|
||||
|
||||
task = self._create_agent_task()
|
||||
response = AgentResponse(messages=[ChatMessage(role="assistant", text='{"name": "test"}')])
|
||||
|
||||
# Value should be None initially
|
||||
assert response.value is None
|
||||
|
||||
# Parse the value
|
||||
task._ensure_response_format(SampleSchema, "test-correlation", response)
|
||||
|
||||
# Value should now be parsed
|
||||
assert isinstance(response.value, SampleSchema)
|
||||
assert response.value.name == "test"
|
||||
|
||||
def test_ensure_response_format_skips_if_already_parsed(self) -> None:
|
||||
"""Test _ensure_response_format does not re-parse if value already matches format."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
class SampleSchema(BaseModel):
|
||||
name: str
|
||||
|
||||
task = self._create_agent_task()
|
||||
existing_value = SampleSchema(name="existing")
|
||||
response = AgentResponse(
|
||||
messages=[ChatMessage(role="assistant", text='{"name": "new"}')],
|
||||
value=existing_value,
|
||||
)
|
||||
|
||||
# Call _ensure_response_format
|
||||
task._ensure_response_format(SampleSchema, "test-correlation", response)
|
||||
|
||||
# Value should remain unchanged (not re-parsed)
|
||||
assert response.value is existing_value
|
||||
assert response.value.name == "existing"
|
||||
|
||||
|
||||
class TestDurableAIAgent:
|
||||
"""Test suite for DurableAIAgent wrapper."""
|
||||
|
||||
def test_init(self) -> None:
|
||||
"""Test DurableAIAgent initialization."""
|
||||
mock_context = Mock()
|
||||
mock_context.instance_id = "test-instance-123"
|
||||
|
||||
agent = DurableAIAgent(mock_context, "TestAgent")
|
||||
|
||||
assert agent.context == mock_context
|
||||
assert agent.agent_name == "TestAgent"
|
||||
|
||||
def test_implements_agent_protocol(self) -> None:
|
||||
"""Test that DurableAIAgent implements AgentProtocol."""
|
||||
from agent_framework import AgentProtocol
|
||||
|
||||
mock_context = Mock()
|
||||
agent = DurableAIAgent(mock_context, "TestAgent")
|
||||
|
||||
# Check that agent satisfies AgentProtocol
|
||||
assert isinstance(agent, AgentProtocol)
|
||||
|
||||
def test_has_agent_protocol_properties(self) -> None:
|
||||
"""Test that DurableAIAgent has AgentProtocol properties."""
|
||||
mock_context = Mock()
|
||||
agent = DurableAIAgent(mock_context, "TestAgent")
|
||||
|
||||
# AgentProtocol properties
|
||||
assert hasattr(agent, "id")
|
||||
assert hasattr(agent, "name")
|
||||
assert hasattr(agent, "description")
|
||||
|
||||
# Verify values
|
||||
assert agent.name == "TestAgent"
|
||||
assert agent.description == "Durable agent proxy for TestAgent"
|
||||
assert agent.id is not None # Auto-generated UUID
|
||||
|
||||
def test_get_new_thread(self) -> None:
|
||||
"""Test creating a new agent thread."""
|
||||
mock_context = Mock()
|
||||
mock_context.instance_id = "test-instance-456"
|
||||
mock_context.new_uuid = Mock(return_value="test-guid-456")
|
||||
|
||||
agent = DurableAIAgent(mock_context, "WriterAgent")
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
assert isinstance(thread, DurableAgentThread)
|
||||
assert thread.session_id is not None
|
||||
session_id = thread.session_id
|
||||
assert isinstance(session_id, AgentSessionId)
|
||||
assert session_id.name == "WriterAgent"
|
||||
assert session_id.key == "test-guid-456"
|
||||
mock_context.new_uuid.assert_called_once()
|
||||
|
||||
def test_get_new_thread_deterministic(self) -> None:
|
||||
"""Test that get_new_thread creates deterministic session IDs."""
|
||||
|
||||
mock_context = Mock()
|
||||
mock_context.instance_id = "test-instance-789"
|
||||
mock_context.new_uuid = Mock(side_effect=["session-guid-1", "session-guid-2"])
|
||||
|
||||
agent = DurableAIAgent(mock_context, "EditorAgent")
|
||||
|
||||
# Create multiple threads - they should have unique session IDs
|
||||
thread1 = agent.get_new_thread()
|
||||
thread2 = agent.get_new_thread()
|
||||
|
||||
assert isinstance(thread1, DurableAgentThread)
|
||||
assert isinstance(thread2, DurableAgentThread)
|
||||
|
||||
session_id1 = thread1.session_id
|
||||
session_id2 = thread2.session_id
|
||||
assert session_id1 is not None and session_id2 is not None
|
||||
assert isinstance(session_id1, AgentSessionId)
|
||||
assert isinstance(session_id2, AgentSessionId)
|
||||
assert session_id1.name == "EditorAgent"
|
||||
assert session_id2.name == "EditorAgent"
|
||||
assert session_id1.key == "session-guid-1"
|
||||
assert session_id2.key == "session-guid-2"
|
||||
assert mock_context.new_uuid.call_count == 2
|
||||
|
||||
def test_run_creates_entity_call(self) -> None:
|
||||
"""Test that run() creates proper entity call and returns a Task."""
|
||||
mock_context = Mock()
|
||||
mock_context.instance_id = "test-instance-001"
|
||||
mock_context.new_uuid = Mock(side_effect=["thread-guid", "correlation-guid"])
|
||||
|
||||
entity_task = _create_entity_task()
|
||||
mock_context.call_entity = Mock(return_value=entity_task)
|
||||
|
||||
agent = DurableAIAgent(mock_context, "TestAgent")
|
||||
|
||||
# Create thread
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
# Call run() - returns AgentTask directly
|
||||
task = agent.run(messages="Test message", thread=thread, enable_tool_calls=True)
|
||||
|
||||
assert isinstance(task, AgentTask)
|
||||
assert task.children[0] == entity_task
|
||||
|
||||
# Verify call_entity was called with correct parameters
|
||||
assert mock_context.call_entity.called
|
||||
call_args = mock_context.call_entity.call_args
|
||||
entity_id, operation, request = call_args[0]
|
||||
|
||||
assert operation == "run"
|
||||
assert request["message"] == "Test message"
|
||||
assert request["enable_tool_calls"] is True
|
||||
assert "correlationId" in request
|
||||
assert request["correlationId"] == "correlation-guid"
|
||||
assert "thread_id" in request
|
||||
assert request["thread_id"] == "thread-guid"
|
||||
# Verify orchestration ID is set from context.instance_id
|
||||
assert "orchestrationId" in request
|
||||
assert request["orchestrationId"] == "test-instance-001"
|
||||
|
||||
def test_run_sets_orchestration_id(self) -> None:
|
||||
"""Test that run() sets the orchestration_id from context.instance_id."""
|
||||
mock_context = Mock()
|
||||
mock_context.instance_id = "my-orchestration-123"
|
||||
mock_context.new_uuid = Mock(side_effect=["thread-guid", "correlation-guid"])
|
||||
|
||||
entity_task = _create_entity_task()
|
||||
mock_context.call_entity = Mock(return_value=entity_task)
|
||||
|
||||
agent = DurableAIAgent(mock_context, "TestAgent")
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
agent.run(messages="Test", thread=thread)
|
||||
|
||||
call_args = mock_context.call_entity.call_args
|
||||
request = call_args[0][2]
|
||||
|
||||
assert request["orchestrationId"] == "my-orchestration-123"
|
||||
|
||||
def test_run_without_thread(self) -> None:
|
||||
"""Test that run() works without explicit thread (creates unique session key)."""
|
||||
mock_context = Mock()
|
||||
mock_context.instance_id = "test-instance-002"
|
||||
mock_context.new_uuid = Mock(side_effect=["auto-generated-guid", "correlation-guid"])
|
||||
|
||||
entity_task = _create_entity_task()
|
||||
mock_context.call_entity = Mock(return_value=entity_task)
|
||||
|
||||
agent = DurableAIAgent(mock_context, "TestAgent")
|
||||
|
||||
# Call without thread
|
||||
task = agent.run(messages="Test message")
|
||||
|
||||
assert isinstance(task, AgentTask)
|
||||
assert task.children[0] == entity_task
|
||||
|
||||
# Verify the entity ID uses the auto-generated GUID with dafx- prefix
|
||||
call_args = mock_context.call_entity.call_args
|
||||
entity_id = call_args[0][0]
|
||||
assert entity_id.name == "dafx-TestAgent"
|
||||
assert entity_id.key == "auto-generated-guid"
|
||||
# Should be called twice: once for session_key, once for correlationId
|
||||
assert mock_context.new_uuid.call_count == 2
|
||||
|
||||
def test_run_with_response_format(self) -> None:
|
||||
"""Test that run() passes response format correctly."""
|
||||
mock_context = Mock()
|
||||
mock_context.instance_id = "test-instance-003"
|
||||
|
||||
entity_task = _create_entity_task()
|
||||
mock_context.call_entity = Mock(return_value=entity_task)
|
||||
|
||||
agent = DurableAIAgent(mock_context, "TestAgent")
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
class SampleSchema(BaseModel):
|
||||
key: str
|
||||
|
||||
# Create thread and call
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
task = agent.run(messages="Test message", thread=thread, options={"response_format": SampleSchema})
|
||||
|
||||
assert isinstance(task, AgentTask)
|
||||
assert task.children[0] == entity_task
|
||||
|
||||
# Verify schema was passed in the call_entity arguments
|
||||
call_args = mock_context.call_entity.call_args
|
||||
input_data = call_args[0][2] # Third argument is input_data
|
||||
assert "response_format" in input_data
|
||||
assert input_data["response_format"]["__response_schema_type__"] == "pydantic_model"
|
||||
assert input_data["response_format"]["module"] == SampleSchema.__module__
|
||||
assert input_data["response_format"]["qualname"] == SampleSchema.__qualname__
|
||||
|
||||
def test_messages_to_string(self) -> None:
|
||||
"""Test converting ChatMessage list to string."""
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
mock_context = Mock()
|
||||
agent = DurableAIAgent(mock_context, "TestAgent")
|
||||
|
||||
messages = [
|
||||
ChatMessage(role="user", text="Hello"),
|
||||
ChatMessage(role="assistant", text="Hi there"),
|
||||
ChatMessage(role="user", text="How are you?"),
|
||||
]
|
||||
|
||||
result = agent._messages_to_string(messages)
|
||||
|
||||
assert result == "Hello\nHi there\nHow are you?"
|
||||
|
||||
def test_run_with_chat_message(self) -> None:
|
||||
"""Test that run() handles ChatMessage input."""
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
mock_context = Mock()
|
||||
mock_context.new_uuid = Mock(side_effect=["thread-guid", "correlation-guid"])
|
||||
entity_task = _create_entity_task()
|
||||
mock_context.call_entity = Mock(return_value=entity_task)
|
||||
|
||||
agent = DurableAIAgent(mock_context, "TestAgent")
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
# Call with ChatMessage
|
||||
msg = ChatMessage(role="user", text="Hello")
|
||||
task = agent.run(messages=msg, thread=thread)
|
||||
|
||||
assert isinstance(task, AgentTask)
|
||||
assert task.children[0] == entity_task
|
||||
|
||||
# Verify message was converted to string
|
||||
call_args = mock_context.call_entity.call_args
|
||||
request = call_args[0][2]
|
||||
assert request["message"] == "Hello"
|
||||
|
||||
def test_run_stream_raises_not_implemented(self) -> None:
|
||||
"""Test that run_stream() method raises NotImplementedError."""
|
||||
mock_context = Mock()
|
||||
agent = DurableAIAgent(mock_context, "TestAgent")
|
||||
|
||||
with pytest.raises(NotImplementedError) as exc_info:
|
||||
agent.run_stream("Test message")
|
||||
|
||||
error_msg = str(exc_info.value)
|
||||
assert "Streaming is not supported" in error_msg
|
||||
|
||||
def test_entity_id_format(self) -> None:
|
||||
"""Test that EntityId is created with correct format (name, key)."""
|
||||
from azure.durable_functions import EntityId
|
||||
|
||||
mock_context = Mock()
|
||||
mock_context.new_uuid = Mock(return_value="test-guid-789")
|
||||
mock_context.call_entity = Mock(return_value=_create_entity_task())
|
||||
|
||||
agent = DurableAIAgent(mock_context, "WriterAgent")
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
# Call run() to trigger entity ID creation
|
||||
agent.run("Test", thread=thread)
|
||||
|
||||
# Verify call_entity was called with correct EntityId
|
||||
call_args = mock_context.call_entity.call_args
|
||||
entity_id = call_args[0][0]
|
||||
|
||||
# EntityId should be EntityId(name="dafx-WriterAgent", key="test-guid-789")
|
||||
# Which formats as "@dafx-writeragent@test-guid-789"
|
||||
assert isinstance(entity_id, EntityId)
|
||||
assert entity_id.name == "dafx-WriterAgent"
|
||||
assert entity_id.key == "test-guid-789"
|
||||
assert str(entity_id) == "@dafx-writeragent@test-guid-789"
|
||||
|
||||
|
||||
class TestAgentFunctionAppGetAgent:
|
||||
"""Test suite for AgentFunctionApp.get_agent."""
|
||||
|
||||
def test_get_agent_method(self) -> None:
|
||||
"""Test get_agent method creates DurableAIAgent for registered agent."""
|
||||
app = _app_with_registered_agents("MyAgent")
|
||||
mock_context = Mock()
|
||||
mock_context.instance_id = "test-instance-100"
|
||||
|
||||
agent = app.get_agent(mock_context, "MyAgent")
|
||||
|
||||
assert isinstance(agent, DurableAIAgent)
|
||||
assert agent.agent_name == "MyAgent"
|
||||
assert agent.context == mock_context
|
||||
|
||||
def test_get_agent_raises_for_unregistered_agent(self) -> None:
|
||||
"""Test get_agent raises ValueError when agent is not registered."""
|
||||
app = _app_with_registered_agents("KnownAgent")
|
||||
@@ -478,18 +204,87 @@ class TestAgentFunctionAppGetAgent:
|
||||
app.get_agent(Mock(), "MissingAgent")
|
||||
|
||||
|
||||
class TestAzureFunctionsFireAndForget:
|
||||
"""Test fire-and-forget mode for AzureFunctionsAgentExecutor."""
|
||||
|
||||
def test_fire_and_forget_calls_signal_entity(self, executor_with_uuid: tuple[Any, Mock, str]) -> None:
|
||||
"""Verify wait_for_response=False calls signal_entity instead of call_entity."""
|
||||
executor, context, _ = executor_with_uuid
|
||||
context.signal_entity = Mock()
|
||||
context.call_entity = Mock(return_value=_create_entity_task())
|
||||
|
||||
agent = DurableAIAgent(executor, "TestAgent")
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
# Run with wait_for_response=False
|
||||
result = agent.run("Test message", thread=thread, options={"wait_for_response": False})
|
||||
|
||||
# Verify signal_entity was called and call_entity was not
|
||||
assert context.signal_entity.call_count == 1
|
||||
assert context.call_entity.call_count == 0
|
||||
|
||||
# Should still return an AgentTask
|
||||
assert isinstance(result, AgentTask)
|
||||
|
||||
def test_fire_and_forget_returns_completed_task(self, executor_with_uuid: tuple[Any, Mock, str]) -> None:
|
||||
"""Verify wait_for_response=False returns pre-completed AgentTask."""
|
||||
executor, context, _ = executor_with_uuid
|
||||
context.signal_entity = Mock()
|
||||
|
||||
agent = DurableAIAgent(executor, "TestAgent")
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
result = agent.run("Test message", thread=thread, options={"wait_for_response": False})
|
||||
|
||||
# Task should be immediately complete
|
||||
assert isinstance(result, AgentTask)
|
||||
assert result.is_completed
|
||||
|
||||
def test_fire_and_forget_returns_acceptance_response(self, executor_with_uuid: tuple[Any, Mock, str]) -> None:
|
||||
"""Verify wait_for_response=False returns acceptance response."""
|
||||
executor, context, _ = executor_with_uuid
|
||||
context.signal_entity = Mock()
|
||||
|
||||
agent = DurableAIAgent(executor, "TestAgent")
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
result = agent.run("Test message", thread=thread, options={"wait_for_response": False})
|
||||
|
||||
# Get the result
|
||||
response = result.result
|
||||
assert isinstance(response, AgentResponse)
|
||||
assert len(response.messages) == 1
|
||||
assert response.messages[0].role == Role.SYSTEM
|
||||
# Check message contains key information
|
||||
message_text = response.messages[0].text
|
||||
assert "accepted" in message_text.lower()
|
||||
assert "background" in message_text.lower()
|
||||
|
||||
def test_blocking_mode_still_works(self, executor_with_uuid: tuple[Any, Mock, str]) -> None:
|
||||
"""Verify wait_for_response=True uses call_entity as before."""
|
||||
executor, context, _ = executor_with_uuid
|
||||
context.signal_entity = Mock()
|
||||
context.call_entity = Mock(return_value=_create_entity_task())
|
||||
|
||||
agent = DurableAIAgent(executor, "TestAgent")
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
result = agent.run("Test message", thread=thread, options={"wait_for_response": True})
|
||||
|
||||
# Verify call_entity was called and signal_entity was not
|
||||
assert context.call_entity.call_count == 1
|
||||
assert context.signal_entity.call_count == 0
|
||||
|
||||
# Should return an AgentTask
|
||||
assert isinstance(result, AgentTask)
|
||||
|
||||
|
||||
class TestOrchestrationIntegration:
|
||||
"""Integration tests for orchestration scenarios."""
|
||||
|
||||
def test_sequential_agent_calls_simulation(self) -> None:
|
||||
def test_sequential_agent_calls_simulation(self, executor_with_multiple_uuids: tuple[Any, Mock, list[str]]) -> None:
|
||||
"""Simulate sequential agent calls in an orchestration."""
|
||||
mock_context = Mock()
|
||||
mock_context.instance_id = "test-orchestration-001"
|
||||
# new_uuid will be called 3 times:
|
||||
# 1. thread creation
|
||||
# 2. correlationId for first call
|
||||
# 3. correlationId for second call
|
||||
mock_context.new_uuid = Mock(side_effect=["deterministic-guid-001", "corr-1", "corr-2"])
|
||||
executor, context, uuid_hexes = executor_with_multiple_uuids
|
||||
|
||||
# Track entity calls
|
||||
entity_calls: list[dict[str, Any]] = []
|
||||
@@ -498,10 +293,10 @@ class TestOrchestrationIntegration:
|
||||
entity_calls.append({"entity_id": str(entity_id), "operation": operation, "input": input_data})
|
||||
return _create_entity_task()
|
||||
|
||||
mock_context.call_entity = Mock(side_effect=mock_call_entity_side_effect)
|
||||
context.call_entity = Mock(side_effect=mock_call_entity_side_effect)
|
||||
|
||||
app = _app_with_registered_agents("WriterAgent")
|
||||
agent = app.get_agent(mock_context, "WriterAgent")
|
||||
# Create agent directly with executor (not via app.get_agent)
|
||||
agent = DurableAIAgent(executor, "WriterAgent")
|
||||
|
||||
# Create thread
|
||||
thread = agent.get_new_thread()
|
||||
@@ -517,18 +312,15 @@ class TestOrchestrationIntegration:
|
||||
# Verify both calls used the same entity (same session key)
|
||||
assert len(entity_calls) == 2
|
||||
assert entity_calls[0]["entity_id"] == entity_calls[1]["entity_id"]
|
||||
# EntityId format is @dafx-writeragent@deterministic-guid-001
|
||||
assert entity_calls[0]["entity_id"] == "@dafx-writeragent@deterministic-guid-001"
|
||||
# new_uuid called 3 times: thread + 2 correlation IDs
|
||||
assert mock_context.new_uuid.call_count == 3
|
||||
# EntityId format is @dafx-writeragent@<uuid_hex>
|
||||
expected_entity_id = f"@dafx-writeragent@{uuid_hexes[0]}"
|
||||
assert entity_calls[0]["entity_id"] == expected_entity_id
|
||||
# generate_unique_id called 3 times: thread + 2 correlation IDs
|
||||
assert executor.generate_unique_id.call_count == 3
|
||||
|
||||
def test_multiple_agents_in_orchestration(self) -> None:
|
||||
def test_multiple_agents_in_orchestration(self, executor_with_multiple_uuids: tuple[Any, Mock, list[str]]) -> None:
|
||||
"""Test using multiple different agents in one orchestration."""
|
||||
mock_context = Mock()
|
||||
mock_context.instance_id = "test-orchestration-002"
|
||||
# Mock new_uuid to return different GUIDs for each call
|
||||
# Order: writer thread, editor thread, writer correlation, editor correlation
|
||||
mock_context.new_uuid = Mock(side_effect=["writer-guid-001", "editor-guid-002", "writer-corr", "editor-corr"])
|
||||
executor, context, uuid_hexes = executor_with_multiple_uuids
|
||||
|
||||
entity_calls: list[str] = []
|
||||
|
||||
@@ -536,11 +328,11 @@ class TestOrchestrationIntegration:
|
||||
entity_calls.append(str(entity_id))
|
||||
return _create_entity_task()
|
||||
|
||||
mock_context.call_entity = Mock(side_effect=mock_call_entity_side_effect)
|
||||
context.call_entity = Mock(side_effect=mock_call_entity_side_effect)
|
||||
|
||||
app = _app_with_registered_agents("WriterAgent", "EditorAgent")
|
||||
writer = app.get_agent(mock_context, "WriterAgent")
|
||||
editor = app.get_agent(mock_context, "EditorAgent")
|
||||
# Create agents directly with executor (not via app.get_agent)
|
||||
writer = DurableAIAgent(executor, "WriterAgent")
|
||||
editor = DurableAIAgent(executor, "EditorAgent")
|
||||
|
||||
writer_thread = writer.get_new_thread()
|
||||
editor_thread = editor.get_new_thread()
|
||||
@@ -554,62 +346,11 @@ class TestOrchestrationIntegration:
|
||||
|
||||
# Verify different entity IDs were used
|
||||
assert len(entity_calls) == 2
|
||||
# EntityId format is @dafx-agentname@guid (lowercased agent name with dafx- prefix)
|
||||
assert entity_calls[0] == "@dafx-writeragent@writer-guid-001"
|
||||
assert entity_calls[1] == "@dafx-editoragent@editor-guid-002"
|
||||
|
||||
|
||||
class TestAgentThreadSerialization:
|
||||
"""Test that AgentThread can be serialized for orchestration state."""
|
||||
|
||||
async def test_agent_thread_serialize(self) -> None:
|
||||
"""Test that AgentThread can be serialized."""
|
||||
thread = AgentThread()
|
||||
|
||||
# Serialize
|
||||
serialized = await thread.serialize()
|
||||
|
||||
assert isinstance(serialized, dict)
|
||||
assert "service_thread_id" in serialized
|
||||
|
||||
async def test_agent_thread_deserialize(self) -> None:
|
||||
"""Test that AgentThread can be deserialized."""
|
||||
thread = AgentThread()
|
||||
serialized = await thread.serialize()
|
||||
|
||||
# Deserialize
|
||||
restored = await AgentThread.deserialize(serialized)
|
||||
|
||||
assert isinstance(restored, AgentThread)
|
||||
assert restored.service_thread_id == thread.service_thread_id
|
||||
|
||||
async def test_durable_agent_thread_serialization(self) -> None:
|
||||
"""Test that DurableAgentThread persists session metadata during serialization."""
|
||||
mock_context = Mock()
|
||||
mock_context.instance_id = "test-instance-999"
|
||||
mock_context.new_uuid = Mock(return_value="test-guid-999")
|
||||
|
||||
agent = DurableAIAgent(mock_context, "TestAgent")
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
assert isinstance(thread, DurableAgentThread)
|
||||
# Verify custom attribute and property exist
|
||||
assert thread.session_id is not None
|
||||
session_id = thread.session_id
|
||||
assert isinstance(session_id, AgentSessionId)
|
||||
assert session_id.name == "TestAgent"
|
||||
assert session_id.key == "test-guid-999"
|
||||
|
||||
# Standard serialization should still work
|
||||
serialized = await thread.serialize()
|
||||
assert isinstance(serialized, dict)
|
||||
assert serialized.get("durable_session_id") == str(session_id)
|
||||
|
||||
# After deserialization, we'd need to restore the custom attribute
|
||||
# This would be handled by the orchestration framework
|
||||
restored = await DurableAgentThread.deserialize(serialized)
|
||||
assert isinstance(restored, DurableAgentThread)
|
||||
assert restored.session_id == session_id
|
||||
# EntityId format is @dafx-agentname@uuid_hex (lowercased agent name with dafx- prefix)
|
||||
expected_writer_id = f"@dafx-writeragent@{uuid_hexes[0]}"
|
||||
expected_editor_id = f"@dafx-editoragent@{uuid_hexes[1]}"
|
||||
assert entity_calls[0] == expected_writer_id
|
||||
assert entity_calls[1] == expected_editor_id
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260123"
|
||||
version = "1.0.0b260116"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260123"
|
||||
version = "1.0.0b260116"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260123"
|
||||
version = "1.0.0b260116"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -4,9 +4,9 @@ import importlib
|
||||
from typing import Any
|
||||
|
||||
_IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"AgentCallbackContext": ("agent_framework_azurefunctions", "agent-framework-azurefunctions"),
|
||||
"AgentCallbackContext": ("agent_framework_durabletask", "agent-framework-durabletask"),
|
||||
"AgentFunctionApp": ("agent_framework_azurefunctions", "agent-framework-azurefunctions"),
|
||||
"AgentResponseCallbackProtocol": ("agent_framework_azurefunctions", "agent-framework-azurefunctions"),
|
||||
"AgentResponseCallbackProtocol": ("agent_framework_durabletask", "agent-framework-durabletask"),
|
||||
"AzureAIAgentClient": ("agent_framework_azure_ai", "agent-framework-azure-ai"),
|
||||
"AzureAIAgentOptions": ("agent_framework_azure_ai", "agent-framework-azure-ai"),
|
||||
"AzureAIProjectAgentOptions": ("agent_framework_azure_ai", "agent-framework-azure-ai"),
|
||||
@@ -24,7 +24,10 @@ _IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"AzureOpenAIResponsesOptions": ("agent_framework.azure._responses_client", "agent-framework-core"),
|
||||
"AzureOpenAISettings": ("agent_framework.azure._shared", "agent-framework-core"),
|
||||
"AzureUserSecurityContext": ("agent_framework.azure._chat_client", "agent-framework-core"),
|
||||
"DurableAIAgent": ("agent_framework_azurefunctions", "agent-framework-azurefunctions"),
|
||||
"DurableAIAgent": ("agent_framework_durabletask", "agent-framework-durabletask"),
|
||||
"DurableAIAgentClient": ("agent_framework_durabletask", "agent-framework-durabletask"),
|
||||
"DurableAIAgentOrchestrationContext": ("agent_framework_durabletask", "agent-framework-durabletask"),
|
||||
"DurableAIAgentWorker": ("agent_framework_durabletask", "agent-framework-durabletask"),
|
||||
"get_entra_auth_token": ("agent_framework.azure._entra_id_authentication", "agent-framework-core"),
|
||||
}
|
||||
|
||||
|
||||
@@ -9,11 +9,14 @@ from agent_framework_azure_ai import (
|
||||
AzureAISettings,
|
||||
)
|
||||
from agent_framework_azure_ai_search import AzureAISearchContextProvider, AzureAISearchSettings
|
||||
from agent_framework_azurefunctions import (
|
||||
from agent_framework_azurefunctions import AgentFunctionApp
|
||||
from agent_framework_durabletask import (
|
||||
AgentCallbackContext,
|
||||
AgentFunctionApp,
|
||||
AgentResponseCallbackProtocol,
|
||||
DurableAIAgent,
|
||||
DurableAIAgentClient,
|
||||
DurableAIAgentOrchestrationContext,
|
||||
DurableAIAgentWorker,
|
||||
)
|
||||
|
||||
from agent_framework.azure._assistants_client import AzureOpenAIAssistantsClient
|
||||
@@ -39,5 +42,8 @@ __all__ = [
|
||||
"AzureOpenAIResponsesClient",
|
||||
"AzureOpenAISettings",
|
||||
"DurableAIAgent",
|
||||
"DurableAIAgentClient",
|
||||
"DurableAIAgentOrchestrationContext",
|
||||
"DurableAIAgentWorker",
|
||||
"get_entra_auth_token",
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260123"
|
||||
version = "1.0.0b260116"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -50,6 +50,7 @@ all = [
|
||||
"agent-framework-copilotstudio",
|
||||
"agent-framework-declarative",
|
||||
"agent-framework-devui",
|
||||
"agent-framework-durabletask",
|
||||
"agent-framework-lab",
|
||||
"agent-framework-mem0",
|
||||
"agent-framework-ollama",
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260123"
|
||||
version = "1.0.0b260116"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260123"
|
||||
version = "1.0.0b260116"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://github.com/microsoft/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
@@ -0,0 +1,31 @@
|
||||
# Get Started with Microsoft Agent Framework Durable Task
|
||||
|
||||
[](https://pypi.org/project/agent-framework-durabletask/)
|
||||
|
||||
Please install this package via pip:
|
||||
|
||||
```bash
|
||||
pip install agent-framework-durabletask --pre
|
||||
```
|
||||
|
||||
## Durable Task Integration
|
||||
|
||||
The durable task integration lets you host Microsoft Agent Framework agents using the [Durable Task](https://github.com/microsoft/durabletask-python) framework so they can persist state, replay conversation history, and recover from failures automatically.
|
||||
|
||||
### Basic Usage Example
|
||||
|
||||
```python
|
||||
from durabletask import TaskHubGrpcWorker
|
||||
from agent_framework.azure import DurableAIAgentWorker
|
||||
|
||||
# Create the worker
|
||||
with DurableTaskSchedulerWorker(...) as worker:
|
||||
|
||||
# Register the agent worker wrapper
|
||||
agent_worker = DurableAIAgentWorker(worker)
|
||||
|
||||
# Register the agent
|
||||
agent_worker.add_agent(my_agent)
|
||||
```
|
||||
|
||||
For more details, review the Python [README](https://github.com/microsoft/agent-framework/tree/main/python/README.md) and the samples directory.
|
||||
@@ -0,0 +1,108 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Durable Task integration for Microsoft Agent Framework."""
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._callbacks import AgentCallbackContext, AgentResponseCallbackProtocol
|
||||
from ._client import DurableAIAgentClient
|
||||
from ._constants import (
|
||||
DEFAULT_MAX_POLL_RETRIES,
|
||||
DEFAULT_POLL_INTERVAL_SECONDS,
|
||||
MIMETYPE_APPLICATION_JSON,
|
||||
MIMETYPE_TEXT_PLAIN,
|
||||
REQUEST_RESPONSE_FORMAT_JSON,
|
||||
REQUEST_RESPONSE_FORMAT_TEXT,
|
||||
THREAD_ID_FIELD,
|
||||
THREAD_ID_HEADER,
|
||||
WAIT_FOR_RESPONSE_FIELD,
|
||||
WAIT_FOR_RESPONSE_HEADER,
|
||||
ApiResponseFields,
|
||||
ContentTypes,
|
||||
DurableStateFields,
|
||||
)
|
||||
from ._durable_agent_state import (
|
||||
DurableAgentState,
|
||||
DurableAgentStateContent,
|
||||
DurableAgentStateData,
|
||||
DurableAgentStateDataContent,
|
||||
DurableAgentStateEntry,
|
||||
DurableAgentStateEntryJsonType,
|
||||
DurableAgentStateErrorContent,
|
||||
DurableAgentStateFunctionCallContent,
|
||||
DurableAgentStateFunctionResultContent,
|
||||
DurableAgentStateHostedFileContent,
|
||||
DurableAgentStateHostedVectorStoreContent,
|
||||
DurableAgentStateMessage,
|
||||
DurableAgentStateRequest,
|
||||
DurableAgentStateResponse,
|
||||
DurableAgentStateTextContent,
|
||||
DurableAgentStateTextReasoningContent,
|
||||
DurableAgentStateUnknownContent,
|
||||
DurableAgentStateUriContent,
|
||||
DurableAgentStateUsage,
|
||||
DurableAgentStateUsageContent,
|
||||
)
|
||||
from ._entities import AgentEntity, AgentEntityStateProviderMixin
|
||||
from ._executors import DurableAgentExecutor
|
||||
from ._models import AgentSessionId, DurableAgentThread, RunRequest
|
||||
from ._orchestration_context import DurableAIAgentOrchestrationContext
|
||||
from ._response_utils import ensure_response_format, load_agent_response
|
||||
from ._shim import DurableAIAgent
|
||||
from ._worker import DurableAIAgentWorker
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0" # Fallback for development mode
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_MAX_POLL_RETRIES",
|
||||
"DEFAULT_POLL_INTERVAL_SECONDS",
|
||||
"MIMETYPE_APPLICATION_JSON",
|
||||
"MIMETYPE_TEXT_PLAIN",
|
||||
"REQUEST_RESPONSE_FORMAT_JSON",
|
||||
"REQUEST_RESPONSE_FORMAT_TEXT",
|
||||
"THREAD_ID_FIELD",
|
||||
"THREAD_ID_HEADER",
|
||||
"WAIT_FOR_RESPONSE_FIELD",
|
||||
"WAIT_FOR_RESPONSE_HEADER",
|
||||
"AgentCallbackContext",
|
||||
"AgentEntity",
|
||||
"AgentEntityStateProviderMixin",
|
||||
"AgentResponseCallbackProtocol",
|
||||
"AgentSessionId",
|
||||
"ApiResponseFields",
|
||||
"ContentTypes",
|
||||
"DurableAIAgent",
|
||||
"DurableAIAgentClient",
|
||||
"DurableAIAgentOrchestrationContext",
|
||||
"DurableAIAgentWorker",
|
||||
"DurableAgentExecutor",
|
||||
"DurableAgentState",
|
||||
"DurableAgentStateContent",
|
||||
"DurableAgentStateData",
|
||||
"DurableAgentStateDataContent",
|
||||
"DurableAgentStateEntry",
|
||||
"DurableAgentStateEntryJsonType",
|
||||
"DurableAgentStateErrorContent",
|
||||
"DurableAgentStateFunctionCallContent",
|
||||
"DurableAgentStateFunctionResultContent",
|
||||
"DurableAgentStateHostedFileContent",
|
||||
"DurableAgentStateHostedVectorStoreContent",
|
||||
"DurableAgentStateMessage",
|
||||
"DurableAgentStateRequest",
|
||||
"DurableAgentStateResponse",
|
||||
"DurableAgentStateTextContent",
|
||||
"DurableAgentStateTextReasoningContent",
|
||||
"DurableAgentStateUnknownContent",
|
||||
"DurableAgentStateUriContent",
|
||||
"DurableAgentStateUsage",
|
||||
"DurableAgentStateUsageContent",
|
||||
"DurableAgentThread",
|
||||
"DurableStateFields",
|
||||
"RunRequest",
|
||||
"__version__",
|
||||
"ensure_response_format",
|
||||
"load_agent_response",
|
||||
]
|
||||
@@ -0,0 +1,90 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Client wrapper for Durable Task Agent Framework.
|
||||
|
||||
This module provides the DurableAIAgentClient class for external clients to interact
|
||||
with durable agents via gRPC.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from agent_framework import AgentResponse, get_logger
|
||||
from durabletask.client import TaskHubGrpcClient
|
||||
|
||||
from ._constants import DEFAULT_MAX_POLL_RETRIES, DEFAULT_POLL_INTERVAL_SECONDS
|
||||
from ._executors import ClientAgentExecutor
|
||||
from ._shim import DurableAgentProvider, DurableAIAgent
|
||||
|
||||
logger = get_logger("agent_framework.durabletask.client")
|
||||
|
||||
|
||||
class DurableAIAgentClient(DurableAgentProvider[AgentResponse]):
|
||||
"""Client wrapper for interacting with durable agents externally.
|
||||
|
||||
This class wraps a durabletask TaskHubGrpcClient and provides a convenient
|
||||
interface for retrieving and executing durable agents from external contexts.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from durabletask import TaskHubGrpcClient
|
||||
from agent_framework.azure import DurableAIAgentClient
|
||||
|
||||
# Create the underlying client
|
||||
client = TaskHubGrpcClient(host_address="localhost:4001")
|
||||
|
||||
# Wrap it with the agent client
|
||||
agent_client = DurableAIAgentClient(client)
|
||||
|
||||
# Get an agent reference
|
||||
agent = agent_client.get_agent("assistant")
|
||||
|
||||
# Run the agent (synchronous call that waits for completion)
|
||||
response = agent.run("Hello, how are you?")
|
||||
print(response.text)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: TaskHubGrpcClient,
|
||||
max_poll_retries: int = DEFAULT_MAX_POLL_RETRIES,
|
||||
poll_interval_seconds: float = DEFAULT_POLL_INTERVAL_SECONDS,
|
||||
):
|
||||
"""Initialize the client wrapper.
|
||||
|
||||
Args:
|
||||
client: The durabletask client instance to wrap
|
||||
max_poll_retries: Maximum polling attempts when waiting for responses
|
||||
poll_interval_seconds: Delay in seconds between polling attempts
|
||||
"""
|
||||
self._client = client
|
||||
|
||||
# Validate and set polling parameters
|
||||
self.max_poll_retries = max(1, max_poll_retries)
|
||||
self.poll_interval_seconds = (
|
||||
poll_interval_seconds if poll_interval_seconds > 0 else DEFAULT_POLL_INTERVAL_SECONDS
|
||||
)
|
||||
|
||||
self._executor = ClientAgentExecutor(self._client, self.max_poll_retries, self.poll_interval_seconds)
|
||||
logger.debug("[DurableAIAgentClient] Initialized with client type: %s", type(client).__name__)
|
||||
|
||||
def get_agent(self, agent_name: str) -> DurableAIAgent[AgentResponse]:
|
||||
"""Retrieve a DurableAIAgent shim for the specified agent.
|
||||
|
||||
This method returns a proxy object that can be used to execute the agent.
|
||||
The actual agent must be registered on a worker with the same name.
|
||||
|
||||
Args:
|
||||
agent_name: Name of the agent to retrieve (without the dafx- prefix)
|
||||
|
||||
Returns:
|
||||
DurableAIAgent instance that can be used to run the agent
|
||||
|
||||
Note:
|
||||
This method does not validate that the agent exists. Validation
|
||||
will occur when the agent is executed. If the entity doesn't exist,
|
||||
the execution will fail with an appropriate error.
|
||||
"""
|
||||
logger.debug("[DurableAIAgentClient] Creating agent proxy for: %s", agent_name)
|
||||
|
||||
return DurableAIAgent(self._executor, agent_name)
|
||||
+104
-54
@@ -30,9 +30,10 @@ All classes support bidirectional conversion between:
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import MutableMapping
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any, cast
|
||||
from typing import Any, ClassVar, cast
|
||||
|
||||
from agent_framework import (
|
||||
AgentResponse,
|
||||
@@ -43,10 +44,10 @@ from agent_framework import (
|
||||
)
|
||||
from dateutil import parser as date_parser
|
||||
|
||||
from ._constants import ApiResponseFields, ContentTypes, DurableStateFields
|
||||
from ._constants import ContentTypes, DurableStateFields
|
||||
from ._models import RunRequest, serialize_response_format
|
||||
|
||||
logger = get_logger("agent_framework.azurefunctions.durable_agent_state")
|
||||
logger = get_logger("agent_framework.durabletask.durable_agent_state")
|
||||
|
||||
|
||||
class DurableAgentStateEntryJsonType(str, Enum):
|
||||
@@ -72,7 +73,10 @@ def _parse_created_at(value: Any) -> datetime:
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
logger.warning("Invalid or missing created_at value in durable agent state; defaulting to current UTC time.")
|
||||
logger.warning(
|
||||
f"Invalid or missing created_at value in durable agent state; defaulting to current UTC time, {value}",
|
||||
stack_info=True,
|
||||
)
|
||||
return datetime.now(tz=timezone.utc)
|
||||
|
||||
|
||||
@@ -258,7 +262,7 @@ class DurableAgentStateContent:
|
||||
"""Convert this durable state content back to an agent framework content object.
|
||||
|
||||
Returns:
|
||||
An agent framework content object (TextContent, FunctionCallContent, etc.)
|
||||
An agent framework content object (Content of type `text`, `function_call`, etc.)
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Must be implemented by subclasses
|
||||
@@ -269,38 +273,42 @@ class DurableAgentStateContent:
|
||||
def from_ai_content(content: Any) -> DurableAgentStateContent:
|
||||
"""Create a durable state content object from an agent framework content object.
|
||||
|
||||
This factory method maps agent framework content types (TextContent, FunctionCallContent,
|
||||
etc.) to their corresponding durable state representations. Unknown content types are
|
||||
wrapped in DurableAgentStateUnknownContent.
|
||||
This factory method maps agent framework content types to their corresponding durable state representations.
|
||||
Unknown content types are wrapped in DurableAgentStateUnknownContent.
|
||||
|
||||
Args:
|
||||
content: An agent framework content object (TextContent, FunctionCallContent, etc.)
|
||||
content: An agent framework content object (Content of type `text`, `function_call`, etc.)
|
||||
|
||||
Returns:
|
||||
The corresponding DurableAgentStateContent subclass instance
|
||||
"""
|
||||
# Map AI content type to appropriate DurableAgentStateContent subclass
|
||||
if isinstance(content, Content) and content.type == "data":
|
||||
return DurableAgentStateDataContent.from_data_content(content)
|
||||
if isinstance(content, Content) and content.type == "error":
|
||||
return DurableAgentStateErrorContent.from_error_content(content)
|
||||
if isinstance(content, Content) and content.type == "function_call":
|
||||
return DurableAgentStateFunctionCallContent.from_function_call_content(content)
|
||||
if isinstance(content, Content) and content.type == "function_result":
|
||||
return DurableAgentStateFunctionResultContent.from_function_result_content(content)
|
||||
if isinstance(content, Content) and content.type == "hosted_file":
|
||||
return DurableAgentStateHostedFileContent.from_hosted_file_content(content)
|
||||
if isinstance(content, Content) and content.type == "hosted_vector_store":
|
||||
return DurableAgentStateHostedVectorStoreContent.from_hosted_vector_store_content(content)
|
||||
if isinstance(content, Content) and content.type == "text":
|
||||
return DurableAgentStateTextContent.from_text_content(content)
|
||||
if isinstance(content, Content) and content.type == "text_reasoning":
|
||||
return DurableAgentStateTextReasoningContent.from_text_reasoning_content(content)
|
||||
if isinstance(content, Content) and content.type == "uri":
|
||||
return DurableAgentStateUriContent.from_uri_content(content)
|
||||
if isinstance(content, Content) and content.type == "usage":
|
||||
return DurableAgentStateUsageContent.from_usage_content(content)
|
||||
return DurableAgentStateUnknownContent.from_unknown_content(content)
|
||||
if not isinstance(content, Content):
|
||||
return DurableAgentStateUnknownContent.from_unknown_content(content)
|
||||
|
||||
match content.type:
|
||||
case "data":
|
||||
return DurableAgentStateDataContent.from_data_content(content)
|
||||
case "error":
|
||||
return DurableAgentStateErrorContent.from_error_content(content)
|
||||
case "function_call":
|
||||
return DurableAgentStateFunctionCallContent.from_function_call_content(content)
|
||||
case "function_result":
|
||||
return DurableAgentStateFunctionResultContent.from_function_result_content(content)
|
||||
case "hosted_file":
|
||||
return DurableAgentStateHostedFileContent.from_hosted_file_content(content)
|
||||
case "hosted_vector_store":
|
||||
return DurableAgentStateHostedVectorStoreContent.from_hosted_vector_store_content(content)
|
||||
case "text":
|
||||
return DurableAgentStateTextContent.from_text_content(content)
|
||||
case "reasoning":
|
||||
return DurableAgentStateTextReasoningContent.from_text_reasoning_content(content)
|
||||
case "uri":
|
||||
return DurableAgentStateUriContent.from_uri_content(content)
|
||||
case "usage":
|
||||
return DurableAgentStateUsageContent.from_usage_content(content)
|
||||
case _:
|
||||
return DurableAgentStateUnknownContent.from_unknown_content(content)
|
||||
|
||||
|
||||
# Core state classes
|
||||
@@ -439,7 +447,7 @@ class DurableAgentState:
|
||||
"""Get the count of conversation entries (requests + responses)."""
|
||||
return len(self.data.conversation_history)
|
||||
|
||||
def try_get_agent_response(self, correlation_id: str) -> dict[str, Any] | None:
|
||||
def try_get_agent_response(self, correlation_id: str) -> AgentResponse | None:
|
||||
"""Try to get an agent response by correlation ID.
|
||||
|
||||
This method searches the conversation history for a response entry matching the given
|
||||
@@ -461,14 +469,8 @@ class DurableAgentState:
|
||||
for entry in self.data.conversation_history:
|
||||
if entry.correlation_id == correlation_id and isinstance(entry, DurableAgentStateResponse):
|
||||
# Found the entry, extract response data
|
||||
# Get the text content from assistant messages only
|
||||
content = "\n".join(message.text for message in entry.messages if message.text)
|
||||
return DurableAgentStateResponse.to_run_response(entry)
|
||||
|
||||
return {
|
||||
ApiResponseFields.CONTENT: content,
|
||||
ApiResponseFields.MESSAGE_COUNT: self.message_count,
|
||||
ApiResponseFields.CORRELATION_ID: correlation_id,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
@@ -689,7 +691,22 @@ class DurableAgentStateResponse(DurableAgentStateEntry):
|
||||
correlation_id=correlation_id,
|
||||
created_at=_parse_created_at(response.created_at),
|
||||
messages=[DurableAgentStateMessage.from_chat_message(m) for m in response.messages],
|
||||
usage=DurableAgentStateUsage.from_usage(response.usage_details), # type: ignore[arg-type]
|
||||
usage=DurableAgentStateUsage.from_usage(response.usage_details),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def to_run_response(
|
||||
response_entry: DurableAgentStateResponse,
|
||||
) -> AgentResponse:
|
||||
"""Converts a DurableAgentStateResponse back to an AgentResponse."""
|
||||
messages = [m.to_chat_message() for m in response_entry.messages]
|
||||
|
||||
usage_details = response_entry.usage.to_usage_details() if response_entry.usage is not None else UsageDetails()
|
||||
|
||||
return AgentResponse(
|
||||
created_at=response_entry.created_at.isoformat(),
|
||||
messages=messages,
|
||||
usage_details=usage_details,
|
||||
)
|
||||
|
||||
|
||||
@@ -859,7 +876,9 @@ class DurableAgentStateDataContent(DurableAgentStateContent):
|
||||
|
||||
@staticmethod
|
||||
def from_data_content(content: Content) -> DurableAgentStateDataContent:
|
||||
return DurableAgentStateDataContent(uri=content.uri, media_type=content.media_type) # type: ignore[arg-type]
|
||||
if content.uri is None:
|
||||
raise ValueError("uri is required for data content")
|
||||
return DurableAgentStateDataContent(uri=content.uri, media_type=content.media_type)
|
||||
|
||||
def to_ai_content(self) -> Content:
|
||||
return Content.from_uri(uri=self.uri, media_type=self.media_type)
|
||||
@@ -940,6 +959,10 @@ class DurableAgentStateFunctionCallContent(DurableAgentStateContent):
|
||||
|
||||
@staticmethod
|
||||
def from_function_call_content(content: Content) -> DurableAgentStateFunctionCallContent:
|
||||
if content.call_id is None:
|
||||
raise ValueError("call_id is required for function call content")
|
||||
if content.name is None:
|
||||
raise ValueError("name is required for function call content")
|
||||
# Ensure arguments is a dict; parse string if needed
|
||||
arguments: dict[str, Any] = {}
|
||||
if content.arguments:
|
||||
@@ -952,7 +975,7 @@ class DurableAgentStateFunctionCallContent(DurableAgentStateContent):
|
||||
except json.JSONDecodeError:
|
||||
arguments = {}
|
||||
|
||||
return DurableAgentStateFunctionCallContent(call_id=content.call_id, name=content.name, arguments=arguments) # type: ignore[arg-type]
|
||||
return DurableAgentStateFunctionCallContent(call_id=content.call_id, name=content.name, arguments=arguments)
|
||||
|
||||
def to_ai_content(self) -> Content:
|
||||
return Content.from_function_call(call_id=self.call_id, name=self.name, arguments=self.arguments)
|
||||
@@ -988,7 +1011,9 @@ class DurableAgentStateFunctionResultContent(DurableAgentStateContent):
|
||||
|
||||
@staticmethod
|
||||
def from_function_result_content(content: Content) -> DurableAgentStateFunctionResultContent:
|
||||
return DurableAgentStateFunctionResultContent(call_id=content.call_id, result=content.result) # type: ignore[arg-type]
|
||||
if content.call_id is None:
|
||||
raise ValueError("call_id is required for function result content")
|
||||
return DurableAgentStateFunctionResultContent(call_id=content.call_id, result=content.result)
|
||||
|
||||
def to_ai_content(self) -> Content:
|
||||
return Content.from_function_result(call_id=self.call_id, result=self.result)
|
||||
@@ -1016,7 +1041,9 @@ class DurableAgentStateHostedFileContent(DurableAgentStateContent):
|
||||
|
||||
@staticmethod
|
||||
def from_hosted_file_content(content: Content) -> DurableAgentStateHostedFileContent:
|
||||
return DurableAgentStateHostedFileContent(file_id=content.file_id) # type: ignore[arg-type]
|
||||
if content.file_id is None:
|
||||
raise ValueError("file_id is required for hosted file content")
|
||||
return DurableAgentStateHostedFileContent(file_id=content.file_id)
|
||||
|
||||
def to_ai_content(self) -> Content:
|
||||
return Content.from_hosted_file(file_id=self.file_id)
|
||||
@@ -1050,7 +1077,9 @@ class DurableAgentStateHostedVectorStoreContent(DurableAgentStateContent):
|
||||
def from_hosted_vector_store_content(
|
||||
content: Content,
|
||||
) -> DurableAgentStateHostedVectorStoreContent:
|
||||
return DurableAgentStateHostedVectorStoreContent(vector_store_id=content.vector_store_id) # type: ignore[arg-type]
|
||||
if content.vector_store_id is None:
|
||||
raise ValueError("vector_store_id is required for hosted vector store content")
|
||||
return DurableAgentStateHostedVectorStoreContent(vector_store_id=content.vector_store_id)
|
||||
|
||||
def to_ai_content(self) -> Content:
|
||||
return Content.from_hosted_vector_store(vector_store_id=self.vector_store_id)
|
||||
@@ -1137,7 +1166,11 @@ class DurableAgentStateUriContent(DurableAgentStateContent):
|
||||
|
||||
@staticmethod
|
||||
def from_uri_content(content: Content) -> DurableAgentStateUriContent:
|
||||
return DurableAgentStateUriContent(uri=content.uri, media_type=content.media_type) # type: ignore[arg-type]
|
||||
if content.uri is None:
|
||||
raise ValueError("uri is required for uri content")
|
||||
if content.media_type is None:
|
||||
raise ValueError("media_type is required for uri content")
|
||||
return DurableAgentStateUriContent(uri=content.uri, media_type=content.media_type)
|
||||
|
||||
def to_ai_content(self) -> Content:
|
||||
return Content.from_uri(uri=self.uri, media_type=self.media_type)
|
||||
@@ -1157,6 +1190,14 @@ class DurableAgentStateUsage:
|
||||
extensionData: Optional additional metadata
|
||||
"""
|
||||
|
||||
# UsageDetails field name constants (snake_case keys from agent_framework.UsageDetails)
|
||||
_INPUT_TOKEN_COUNT = "input_token_count" # noqa: S105 # nosec B105
|
||||
_OUTPUT_TOKEN_COUNT = "output_token_count" # noqa: S105 # nosec B105
|
||||
_TOTAL_TOKEN_COUNT = "total_token_count" # noqa: S105 # nosec B105
|
||||
|
||||
# Standard fields in UsageDetails that are mapped to dedicated attributes
|
||||
_STANDARD_USAGE_FIELDS: ClassVar[set[str]] = {_INPUT_TOKEN_COUNT, _OUTPUT_TOKEN_COUNT, _TOTAL_TOKEN_COUNT}
|
||||
|
||||
input_token_count: int | None = None
|
||||
output_token_count: int | None = None
|
||||
total_token_count: int | None = None
|
||||
@@ -1194,22 +1235,31 @@ class DurableAgentStateUsage:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def from_usage(usage: UsageDetails | dict[str, int] | None) -> DurableAgentStateUsage | None:
|
||||
def from_usage(usage: UsageDetails | MutableMapping[str, int] | None) -> DurableAgentStateUsage | None:
|
||||
if usage is None:
|
||||
return None
|
||||
|
||||
# Collect all non-standard fields into extension_data
|
||||
extension_data: dict[str, Any] = {
|
||||
k: v for k, v in usage.items() if k not in DurableAgentStateUsage._STANDARD_USAGE_FIELDS
|
||||
}
|
||||
|
||||
return DurableAgentStateUsage(
|
||||
input_token_count=usage.get("input_token_count"),
|
||||
output_token_count=usage.get("output_token_count"),
|
||||
total_token_count=usage.get("total_token_count"),
|
||||
input_token_count=usage.get(DurableAgentStateUsage._INPUT_TOKEN_COUNT),
|
||||
output_token_count=usage.get(DurableAgentStateUsage._OUTPUT_TOKEN_COUNT),
|
||||
total_token_count=usage.get(DurableAgentStateUsage._TOTAL_TOKEN_COUNT),
|
||||
extensionData=extension_data if extension_data else None,
|
||||
)
|
||||
|
||||
def to_usage_details(self) -> UsageDetails:
|
||||
# Convert back to AI SDK UsageDetails
|
||||
return {
|
||||
"input_token_count": self.input_token_count,
|
||||
"output_token_count": self.output_token_count,
|
||||
"total_token_count": self.total_token_count,
|
||||
}
|
||||
|
||||
return UsageDetails(
|
||||
input_token_count=self.input_token_count,
|
||||
output_token_count=self.output_token_count,
|
||||
total_token_count=self.total_token_count,
|
||||
**self.extensionData if self.extensionData else {},
|
||||
)
|
||||
|
||||
|
||||
class DurableAgentStateUsageContent(DurableAgentStateContent):
|
||||
@@ -0,0 +1,347 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Durable Task entity implementations for Microsoft Agent Framework."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from collections.abc import AsyncIterable
|
||||
from typing import Any, cast
|
||||
|
||||
from agent_framework import (
|
||||
AgentProtocol,
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
ChatMessage,
|
||||
Content,
|
||||
Role,
|
||||
get_logger,
|
||||
)
|
||||
from durabletask.entities import DurableEntity
|
||||
|
||||
from ._callbacks import AgentCallbackContext, AgentResponseCallbackProtocol
|
||||
from ._durable_agent_state import (
|
||||
DurableAgentState,
|
||||
DurableAgentStateEntry,
|
||||
DurableAgentStateRequest,
|
||||
DurableAgentStateResponse,
|
||||
)
|
||||
from ._models import RunRequest
|
||||
|
||||
logger = get_logger("agent_framework.durabletask.entities")
|
||||
|
||||
|
||||
class AgentEntityStateProviderMixin:
|
||||
"""Mixin implementing durable agent state caching + (de)serialization + persistence.
|
||||
|
||||
Concrete classes must implement:
|
||||
- _get_state_dict(): fetch raw persisted state dict (default should be {})
|
||||
- _set_state_dict(): persist raw state dict
|
||||
- _get_thread_id_from_entity(): fetch the thread ID from the underlying context
|
||||
"""
|
||||
|
||||
_state_cache: DurableAgentState | None = None
|
||||
|
||||
def _get_state_dict(self) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
def _set_state_dict(self, state: dict[str, Any]) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def _get_thread_id_from_entity(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def thread_id(self) -> str:
|
||||
return self._get_thread_id_from_entity()
|
||||
|
||||
@property
|
||||
def state(self) -> DurableAgentState:
|
||||
if self._state_cache is None:
|
||||
raw_state = self._get_state_dict()
|
||||
self._state_cache = DurableAgentState.from_dict(raw_state) if raw_state else DurableAgentState()
|
||||
return self._state_cache
|
||||
|
||||
@state.setter
|
||||
def state(self, value: DurableAgentState) -> None:
|
||||
self._state_cache = value
|
||||
self.persist_state()
|
||||
|
||||
def persist_state(self) -> None:
|
||||
"""Persist the current state to the underlying storage provider."""
|
||||
if self._state_cache is None:
|
||||
self._state_cache = DurableAgentState()
|
||||
self._set_state_dict(self._state_cache.to_dict())
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Clear conversation history by resetting state to a fresh DurableAgentState."""
|
||||
self._state_cache = DurableAgentState()
|
||||
self.persist_state()
|
||||
logger.debug("[AgentEntityStateProviderMixin.reset] State reset complete")
|
||||
|
||||
|
||||
class AgentEntity:
|
||||
"""Platform-agnostic agent execution logic.
|
||||
|
||||
This class encapsulates the core logic for executing an agent within a durable entity context.
|
||||
"""
|
||||
|
||||
agent: AgentProtocol
|
||||
callback: AgentResponseCallbackProtocol | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent: AgentProtocol,
|
||||
callback: AgentResponseCallbackProtocol | None = None,
|
||||
*,
|
||||
state_provider: AgentEntityStateProviderMixin,
|
||||
) -> None:
|
||||
self.agent = agent
|
||||
self.callback = callback
|
||||
self._state_provider = state_provider
|
||||
|
||||
logger.debug("[AgentEntity] Initialized with agent type: %s", type(agent).__name__)
|
||||
|
||||
@property
|
||||
def state(self) -> DurableAgentState:
|
||||
return self._state_provider.state
|
||||
|
||||
@state.setter
|
||||
def state(self, value: DurableAgentState) -> None:
|
||||
self._state_provider.state = value
|
||||
|
||||
def persist_state(self) -> None:
|
||||
self._state_provider.persist_state()
|
||||
|
||||
def reset(self) -> None:
|
||||
self._state_provider.reset()
|
||||
|
||||
def _is_error_response(self, entry: DurableAgentStateEntry) -> bool:
|
||||
"""Check if a conversation history entry is an error response."""
|
||||
if isinstance(entry, DurableAgentStateResponse):
|
||||
return entry.is_error
|
||||
return False
|
||||
|
||||
async def run(
|
||||
self,
|
||||
request: RunRequest | dict[str, Any] | str,
|
||||
) -> AgentResponse:
|
||||
"""Execute the agent with a message."""
|
||||
if isinstance(request, str):
|
||||
run_request = RunRequest.from_json(request)
|
||||
elif isinstance(request, dict):
|
||||
run_request = RunRequest.from_dict(request)
|
||||
else:
|
||||
run_request = request
|
||||
|
||||
message = run_request.message
|
||||
thread_id = self._state_provider.thread_id
|
||||
correlation_id = run_request.correlation_id
|
||||
if not thread_id:
|
||||
raise ValueError("Entity State Provider must provide a thread_id")
|
||||
options: dict[str, Any] = dict(run_request.options)
|
||||
options.setdefault("response_format", run_request.response_format)
|
||||
if not run_request.enable_tool_calls:
|
||||
options.setdefault("tools", None)
|
||||
|
||||
logger.debug("[AgentEntity.run] Received ThreadId %s Message: %s", thread_id, run_request)
|
||||
|
||||
state_request = DurableAgentStateRequest.from_run_request(run_request)
|
||||
self.state.data.conversation_history.append(state_request)
|
||||
|
||||
try:
|
||||
chat_messages: list[ChatMessage] = [
|
||||
m.to_chat_message()
|
||||
for entry in self.state.data.conversation_history
|
||||
if not self._is_error_response(entry)
|
||||
for m in entry.messages
|
||||
]
|
||||
|
||||
run_kwargs: dict[str, Any] = {"messages": chat_messages, "options": options}
|
||||
|
||||
agent_run_response: AgentResponse = await self._invoke_agent(
|
||||
run_kwargs=run_kwargs,
|
||||
correlation_id=correlation_id,
|
||||
thread_id=thread_id,
|
||||
request_message=message,
|
||||
)
|
||||
|
||||
state_response = DurableAgentStateResponse.from_run_response(correlation_id, agent_run_response)
|
||||
self.state.data.conversation_history.append(state_response)
|
||||
self.persist_state()
|
||||
|
||||
return agent_run_response
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("[AgentEntity.run] Agent execution failed.")
|
||||
|
||||
error_message = ChatMessage(
|
||||
role=Role.ASSISTANT, contents=[Content.from_error(message=str(exc), error_code=type(exc).__name__)]
|
||||
)
|
||||
error_response = AgentResponse(messages=[error_message])
|
||||
|
||||
error_state_response = DurableAgentStateResponse.from_run_response(correlation_id, error_response)
|
||||
error_state_response.is_error = True
|
||||
self.state.data.conversation_history.append(error_state_response)
|
||||
self.persist_state()
|
||||
|
||||
return error_response
|
||||
|
||||
async def _invoke_agent(
|
||||
self,
|
||||
run_kwargs: dict[str, Any],
|
||||
correlation_id: str,
|
||||
thread_id: str,
|
||||
request_message: str,
|
||||
) -> AgentResponse:
|
||||
"""Execute the agent, preferring streaming when available."""
|
||||
callback_context: AgentCallbackContext | None = None
|
||||
if self.callback is not None:
|
||||
callback_context = self._build_callback_context(
|
||||
correlation_id=correlation_id,
|
||||
thread_id=thread_id,
|
||||
request_message=request_message,
|
||||
)
|
||||
|
||||
run_stream_callable = getattr(self.agent, "run_stream", None)
|
||||
if callable(run_stream_callable):
|
||||
try:
|
||||
stream_candidate = run_stream_callable(**run_kwargs)
|
||||
if inspect.isawaitable(stream_candidate):
|
||||
stream_candidate = await stream_candidate
|
||||
|
||||
return await self._consume_stream(
|
||||
stream=cast(AsyncIterable[AgentResponseUpdate], stream_candidate),
|
||||
callback_context=callback_context,
|
||||
)
|
||||
except TypeError as type_error:
|
||||
if "__aiter__" not in str(type_error):
|
||||
raise
|
||||
logger.debug(
|
||||
"run_stream returned a non-async result; falling back to run(): %s",
|
||||
type_error,
|
||||
)
|
||||
except Exception as stream_error:
|
||||
logger.warning(
|
||||
"run_stream failed; falling back to run(): %s",
|
||||
stream_error,
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
logger.debug("Agent does not expose run_stream; falling back to run().")
|
||||
|
||||
agent_run_response = await self._invoke_non_stream(run_kwargs)
|
||||
await self._notify_final_response(agent_run_response, callback_context)
|
||||
return agent_run_response
|
||||
|
||||
async def _consume_stream(
|
||||
self,
|
||||
stream: AsyncIterable[AgentResponseUpdate],
|
||||
callback_context: AgentCallbackContext | None = None,
|
||||
) -> AgentResponse:
|
||||
"""Consume streaming responses and build the final AgentResponse."""
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
|
||||
async for update in stream:
|
||||
updates.append(update)
|
||||
await self._notify_stream_update(update, callback_context)
|
||||
|
||||
if updates:
|
||||
response = AgentResponse.from_agent_run_response_updates(updates)
|
||||
else:
|
||||
logger.debug("[AgentEntity] No streaming updates received; creating empty response")
|
||||
response = AgentResponse(messages=[])
|
||||
|
||||
await self._notify_final_response(response, callback_context)
|
||||
return response
|
||||
|
||||
async def _invoke_non_stream(self, run_kwargs: dict[str, Any]) -> AgentResponse:
|
||||
"""Invoke the agent without streaming support."""
|
||||
run_callable = getattr(self.agent, "run", None)
|
||||
if run_callable is None or not callable(run_callable):
|
||||
raise AttributeError("Agent does not implement run() method")
|
||||
|
||||
result = run_callable(**run_kwargs)
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
|
||||
if not isinstance(result, AgentResponse):
|
||||
raise TypeError(f"Agent run() must return an AgentResponse instance; received {type(result).__name__}")
|
||||
|
||||
return result
|
||||
|
||||
async def _notify_stream_update(
|
||||
self,
|
||||
update: AgentResponseUpdate,
|
||||
context: AgentCallbackContext | None,
|
||||
) -> None:
|
||||
"""Invoke the streaming callback if one is registered."""
|
||||
if self.callback is None or context is None:
|
||||
return
|
||||
|
||||
try:
|
||||
callback_result = self.callback.on_streaming_response_update(update, context)
|
||||
if inspect.isawaitable(callback_result):
|
||||
await callback_result
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[AgentEntity] Streaming callback raised an exception: %s",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def _notify_final_response(
|
||||
self,
|
||||
response: AgentResponse,
|
||||
context: AgentCallbackContext | None,
|
||||
) -> None:
|
||||
"""Invoke the final response callback if one is registered."""
|
||||
if self.callback is None or context is None:
|
||||
return
|
||||
|
||||
try:
|
||||
callback_result = self.callback.on_agent_response(response, context)
|
||||
if inspect.isawaitable(callback_result):
|
||||
await callback_result
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[AgentEntity] Response callback raised an exception: %s",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _build_callback_context(
|
||||
self,
|
||||
correlation_id: str,
|
||||
thread_id: str,
|
||||
request_message: str,
|
||||
) -> AgentCallbackContext:
|
||||
"""Create the callback context provided to consumers."""
|
||||
agent_name = getattr(self.agent, "name", None) or type(self.agent).__name__
|
||||
return AgentCallbackContext(
|
||||
agent_name=agent_name,
|
||||
correlation_id=correlation_id,
|
||||
thread_id=thread_id,
|
||||
request_message=request_message,
|
||||
)
|
||||
|
||||
|
||||
class DurableTaskEntityStateProvider(DurableEntity, AgentEntityStateProviderMixin):
|
||||
"""DurableTask Durable Entity state provider for AgentEntity.
|
||||
|
||||
This class utilizes the Durable Entity context from `durabletask` package
|
||||
to get and set the state of the agent entity.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
def _get_state_dict(self) -> dict[str, Any]:
|
||||
raw = self.get_state(dict, default={})
|
||||
return cast(dict[str, Any], raw)
|
||||
|
||||
def _set_state_dict(self, state: dict[str, Any]) -> None:
|
||||
self.set_state(state)
|
||||
|
||||
def _get_thread_id_from_entity(self) -> str:
|
||||
return self.entity_context.entity_id.key
|
||||
@@ -0,0 +1,515 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Provider strategies for Durable Agent execution.
|
||||
|
||||
These classes are internal execution strategies used by the DurableAIAgent shim.
|
||||
They are intentionally separate from the public client/orchestration APIs to keep
|
||||
only `get_agent` exposed to consumers. Executors implement the execution contract
|
||||
and are injected into the shim.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from agent_framework import AgentResponse, AgentThread, ChatMessage, Content, Role, get_logger
|
||||
from durabletask.client import TaskHubGrpcClient
|
||||
from durabletask.entities import EntityInstanceId
|
||||
from durabletask.task import CompletableTask, CompositeTask, OrchestrationContext, Task
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ._constants import DEFAULT_MAX_POLL_RETRIES, DEFAULT_POLL_INTERVAL_SECONDS
|
||||
from ._durable_agent_state import DurableAgentState
|
||||
from ._models import AgentSessionId, DurableAgentThread, RunRequest
|
||||
from ._response_utils import ensure_response_format, load_agent_response
|
||||
|
||||
logger = get_logger("agent_framework.durabletask.executors")
|
||||
|
||||
# TypeVar for the task type returned by executors
|
||||
TaskT = TypeVar("TaskT")
|
||||
|
||||
|
||||
class DurableAgentTask(CompositeTask[AgentResponse], CompletableTask[AgentResponse]):
|
||||
"""A custom Task that wraps entity calls and provides typed AgentResponse results.
|
||||
|
||||
This task wraps the underlying entity call task and intercepts its completion
|
||||
to convert the raw result into a typed AgentResponse object.
|
||||
|
||||
When yielded in an orchestration, this task returns an AgentResponse:
|
||||
response: AgentResponse = yield durable_agent_task
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entity_task: CompletableTask[Any],
|
||||
response_format: type[BaseModel] | None,
|
||||
correlation_id: str,
|
||||
):
|
||||
"""Initialize the DurableAgentTask.
|
||||
|
||||
Args:
|
||||
entity_task: The underlying entity call task
|
||||
response_format: Optional Pydantic model for response parsing
|
||||
correlation_id: Correlation ID for logging
|
||||
"""
|
||||
self._response_format = response_format
|
||||
self._correlation_id = correlation_id
|
||||
super().__init__([entity_task]) # type: ignore
|
||||
|
||||
def on_child_completed(self, task: Task[Any]) -> None:
|
||||
"""Handle completion of the underlying entity task.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
task : Task
|
||||
The entity call task that just completed
|
||||
"""
|
||||
if self.is_complete:
|
||||
return
|
||||
|
||||
if task.is_failed:
|
||||
# Propagate the failure - pass the original exception directly
|
||||
self.fail("call_entity Task failed", task.get_exception())
|
||||
return
|
||||
|
||||
# Task succeeded - transform the raw result
|
||||
raw_result = task.get_result()
|
||||
logger.debug(
|
||||
"[DurableAgentTask] Converting raw result for correlation_id %s",
|
||||
self._correlation_id,
|
||||
)
|
||||
|
||||
try:
|
||||
response = load_agent_response(raw_result)
|
||||
|
||||
if self._response_format is not None:
|
||||
ensure_response_format(
|
||||
self._response_format,
|
||||
self._correlation_id,
|
||||
response,
|
||||
)
|
||||
|
||||
# Set the typed AgentResponse as this task's result
|
||||
self.complete(response)
|
||||
|
||||
except Exception as ex:
|
||||
err_msg = "[DurableAgentTask] Failed to convert result for correlation_id: " + self._correlation_id
|
||||
logger.exception(err_msg)
|
||||
self.fail(err_msg, ex)
|
||||
|
||||
|
||||
class DurableAgentExecutor(ABC, Generic[TaskT]):
|
||||
"""Abstract base class for durable agent execution strategies.
|
||||
|
||||
Type Parameters:
|
||||
TaskT: The task type returned by this executor
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def run_durable_agent(
|
||||
self,
|
||||
agent_name: str,
|
||||
run_request: RunRequest,
|
||||
thread: AgentThread | None = None,
|
||||
) -> TaskT:
|
||||
"""Execute the durable agent.
|
||||
|
||||
Returns:
|
||||
TaskT: The task type specific to this executor implementation
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_new_thread(self, agent_name: str, **kwargs: Any) -> DurableAgentThread:
|
||||
"""Create a new DurableAgentThread with random session ID."""
|
||||
session_id = self._create_session_id(agent_name)
|
||||
return DurableAgentThread.from_session_id(session_id, **kwargs)
|
||||
|
||||
def _create_session_id(
|
||||
self,
|
||||
agent_name: str,
|
||||
thread: AgentThread | None = None,
|
||||
) -> AgentSessionId:
|
||||
"""Create the AgentSessionId for the execution."""
|
||||
if isinstance(thread, DurableAgentThread) and thread.session_id is not None:
|
||||
return thread.session_id
|
||||
# Create new session ID - either no thread provided or it's a regular AgentThread
|
||||
key = self.generate_unique_id()
|
||||
return AgentSessionId(name=agent_name, key=key)
|
||||
|
||||
def generate_unique_id(self) -> str:
|
||||
"""Generate a new Unique ID."""
|
||||
return uuid.uuid4().hex
|
||||
|
||||
def get_run_request(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
options: dict[str, Any] | None = None,
|
||||
) -> RunRequest:
|
||||
"""Create a RunRequest from message and options."""
|
||||
correlation_id = self.generate_unique_id()
|
||||
|
||||
# Create a copy to avoid modifying the caller's dict
|
||||
opts = dict(options) if options else {}
|
||||
|
||||
# Extract and REMOVE known keys from options copy
|
||||
response_format = opts.pop("response_format", None)
|
||||
enable_tool_calls = opts.pop("enable_tool_calls", True)
|
||||
wait_for_response = opts.pop("wait_for_response", True)
|
||||
|
||||
return RunRequest(
|
||||
message=message,
|
||||
response_format=response_format,
|
||||
enable_tool_calls=enable_tool_calls,
|
||||
wait_for_response=wait_for_response,
|
||||
correlation_id=correlation_id,
|
||||
options=opts,
|
||||
)
|
||||
|
||||
def _create_acceptance_response(self, correlation_id: str) -> AgentResponse:
|
||||
"""Create an acceptance response for fire-and-forget mode.
|
||||
|
||||
Args:
|
||||
correlation_id: Correlation ID for tracking the request
|
||||
|
||||
Returns:
|
||||
AgentResponse: Acceptance response with correlation ID
|
||||
"""
|
||||
acceptance_message = ChatMessage(
|
||||
role=Role.SYSTEM,
|
||||
contents=[
|
||||
Content.from_text(
|
||||
f"Request accepted for processing (correlation_id: {correlation_id}). "
|
||||
f"Agent is executing in the background. "
|
||||
f"Retrieve response via your configured streaming or callback mechanism."
|
||||
)
|
||||
],
|
||||
)
|
||||
return AgentResponse(
|
||||
messages=[acceptance_message],
|
||||
created_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
|
||||
class ClientAgentExecutor(DurableAgentExecutor[AgentResponse]):
|
||||
"""Execution strategy for external clients.
|
||||
|
||||
Note: Returns AgentResponse directly since the execution
|
||||
is blocking until response is available via polling
|
||||
as per the design of TaskHubGrpcClient.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: TaskHubGrpcClient,
|
||||
max_poll_retries: int = DEFAULT_MAX_POLL_RETRIES,
|
||||
poll_interval_seconds: float = DEFAULT_POLL_INTERVAL_SECONDS,
|
||||
):
|
||||
self._client = client
|
||||
self.max_poll_retries = max_poll_retries
|
||||
self.poll_interval_seconds = poll_interval_seconds
|
||||
|
||||
def run_durable_agent(
|
||||
self,
|
||||
agent_name: str,
|
||||
run_request: RunRequest,
|
||||
thread: AgentThread | None = None,
|
||||
) -> AgentResponse:
|
||||
"""Execute the agent via the durabletask client.
|
||||
|
||||
Signals the agent entity with a message request, then polls the entity
|
||||
state to retrieve the response once processing is complete.
|
||||
|
||||
Note: This is a blocking/synchronous operation (in line with how
|
||||
TaskHubGrpcClient works) that polls until a response is available or
|
||||
timeout occurs.
|
||||
|
||||
Args:
|
||||
agent_name: Name of the agent to execute
|
||||
run_request: The run request containing message and optional response format
|
||||
thread: Optional conversation thread (creates new if not provided)
|
||||
|
||||
Returns:
|
||||
AgentResponse: The agent's response after execution completes, or an immediate
|
||||
acknowledgement if wait_for_response is False
|
||||
"""
|
||||
# Signal the entity with the request
|
||||
entity_id = self._signal_agent_entity(agent_name, run_request, thread)
|
||||
|
||||
# If fire-and-forget mode, return immediately without polling
|
||||
if not run_request.wait_for_response:
|
||||
logger.info(
|
||||
"[ClientAgentExecutor] Fire-and-forget mode: request signaled (correlation: %s)",
|
||||
run_request.correlation_id,
|
||||
)
|
||||
return self._create_acceptance_response(run_request.correlation_id)
|
||||
|
||||
# Poll for the response
|
||||
agent_response = self._poll_for_agent_response(entity_id, run_request.correlation_id)
|
||||
|
||||
# Handle and return the result
|
||||
return self._handle_agent_response(agent_response, run_request.response_format, run_request.correlation_id)
|
||||
|
||||
def _signal_agent_entity(
|
||||
self,
|
||||
agent_name: str,
|
||||
run_request: RunRequest,
|
||||
thread: AgentThread | None,
|
||||
) -> EntityInstanceId:
|
||||
"""Signal the agent entity with a run request.
|
||||
|
||||
Args:
|
||||
agent_name: Name of the agent to execute
|
||||
run_request: The run request containing message and optional response format
|
||||
thread: Optional conversation thread
|
||||
|
||||
Returns:
|
||||
entity_id
|
||||
"""
|
||||
# Get or create session ID
|
||||
session_id = self._create_session_id(agent_name, thread)
|
||||
|
||||
# Create the entity ID
|
||||
entity_id = EntityInstanceId(
|
||||
entity=session_id.entity_name,
|
||||
key=session_id.key,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"[ClientAgentExecutor] Signaling entity '%s' (session: %s, correlation: %s)",
|
||||
agent_name,
|
||||
session_id,
|
||||
run_request.correlation_id,
|
||||
)
|
||||
|
||||
self._client.signal_entity(entity_id, "run", run_request.to_dict())
|
||||
return entity_id
|
||||
|
||||
def _poll_for_agent_response(
|
||||
self,
|
||||
entity_id: EntityInstanceId,
|
||||
correlation_id: str,
|
||||
) -> AgentResponse | None:
|
||||
"""Poll the entity for a response with retries.
|
||||
|
||||
Args:
|
||||
entity_id: Entity instance identifier
|
||||
correlation_id: Correlation ID to track the request
|
||||
|
||||
Returns:
|
||||
The agent response if found, None if timeout occurs
|
||||
"""
|
||||
agent_response = None
|
||||
|
||||
for attempt in range(1, self.max_poll_retries + 1):
|
||||
time.sleep(self.poll_interval_seconds)
|
||||
|
||||
agent_response = self._poll_entity_for_response(entity_id, correlation_id)
|
||||
if agent_response is not None:
|
||||
logger.info(
|
||||
"[ClientAgentExecutor] Found response (attempt %d/%d, correlation: %s)",
|
||||
attempt,
|
||||
self.max_poll_retries,
|
||||
correlation_id,
|
||||
)
|
||||
break
|
||||
|
||||
logger.debug(
|
||||
"[ClientAgentExecutor] Response not ready (attempt %d/%d)",
|
||||
attempt,
|
||||
self.max_poll_retries,
|
||||
)
|
||||
|
||||
return agent_response
|
||||
|
||||
def _handle_agent_response(
|
||||
self,
|
||||
agent_response: AgentResponse | None,
|
||||
response_format: type[BaseModel] | None,
|
||||
correlation_id: str,
|
||||
) -> AgentResponse:
|
||||
"""Handle the agent response or create an error response.
|
||||
|
||||
Args:
|
||||
agent_response: The response from polling, or None if timeout
|
||||
response_format: Optional response format for validation
|
||||
correlation_id: Correlation ID for logging
|
||||
|
||||
Returns:
|
||||
AgentResponse with either the agent's response or an error message
|
||||
"""
|
||||
if agent_response is not None:
|
||||
try:
|
||||
# Validate response format if specified
|
||||
if response_format is not None:
|
||||
ensure_response_format(
|
||||
response_format,
|
||||
correlation_id,
|
||||
agent_response,
|
||||
)
|
||||
|
||||
return agent_response
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"[ClientAgentExecutor] Error converting response for correlation: %s",
|
||||
correlation_id,
|
||||
)
|
||||
error_message = ChatMessage(
|
||||
role=Role.SYSTEM,
|
||||
contents=[
|
||||
Content.from_error(
|
||||
message=f"Error processing agent response: {e}",
|
||||
error_code="response_processing_error",
|
||||
)
|
||||
],
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"[ClientAgentExecutor] Timeout after %d attempts (correlation: %s)",
|
||||
self.max_poll_retries,
|
||||
correlation_id,
|
||||
)
|
||||
error_message = ChatMessage(
|
||||
role=Role.SYSTEM,
|
||||
contents=[
|
||||
Content.from_error(
|
||||
message=f"Timeout waiting for agent response after {self.max_poll_retries} attempts",
|
||||
error_code="response_timeout",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
return AgentResponse(
|
||||
messages=[error_message],
|
||||
created_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
def _poll_entity_for_response(
|
||||
self,
|
||||
entity_id: EntityInstanceId,
|
||||
correlation_id: str,
|
||||
) -> AgentResponse | None:
|
||||
"""Poll the entity state for a response matching the correlation ID.
|
||||
|
||||
Args:
|
||||
entity_id: Entity instance identifier
|
||||
correlation_id: Correlation ID to search for
|
||||
|
||||
Returns:
|
||||
Response AgentResponse, None otherwise
|
||||
"""
|
||||
try:
|
||||
entity_metadata = self._client.get_entity(entity_id, include_state=True)
|
||||
|
||||
if entity_metadata is None:
|
||||
return None
|
||||
|
||||
state_json = entity_metadata.get_state()
|
||||
if not state_json:
|
||||
return None
|
||||
|
||||
state = DurableAgentState.from_json(state_json)
|
||||
|
||||
# Use the helper method to get response by correlation ID
|
||||
return state.try_get_agent_response(correlation_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[ClientAgentExecutor] Error reading entity state: %s",
|
||||
e,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class OrchestrationAgentExecutor(DurableAgentExecutor[DurableAgentTask]):
|
||||
"""Execution strategy for orchestrations (sync/yield)."""
|
||||
|
||||
def __init__(self, context: OrchestrationContext):
|
||||
self._context = context
|
||||
logger.debug("[OrchestrationAgentExecutor] Initialized")
|
||||
|
||||
def generate_unique_id(self) -> str:
|
||||
"""Create a new UUID that is safe for replay within an orchestration or operation."""
|
||||
return self._context.new_uuid()
|
||||
|
||||
def get_run_request(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
options: dict[str, Any] | None = None,
|
||||
) -> RunRequest:
|
||||
"""Get the current run request from the orchestration context.
|
||||
|
||||
Returns:
|
||||
RunRequest: The current run request
|
||||
"""
|
||||
request = super().get_run_request(
|
||||
message,
|
||||
options=options,
|
||||
)
|
||||
request.orchestration_id = self._context.instance_id
|
||||
return request
|
||||
|
||||
def run_durable_agent(
|
||||
self,
|
||||
agent_name: str,
|
||||
run_request: RunRequest,
|
||||
thread: AgentThread | None = None,
|
||||
) -> DurableAgentTask:
|
||||
"""Execute the agent via orchestration context.
|
||||
|
||||
Calls the agent entity and returns a DurableAgentTask that can be yielded
|
||||
in orchestrations to wait for the entity's response.
|
||||
|
||||
Args:
|
||||
agent_name: Name of the agent to execute
|
||||
run_request: The run request containing message and optional response format
|
||||
thread: Optional conversation thread (creates new if not provided)
|
||||
|
||||
Returns:
|
||||
DurableAgentTask: A task wrapping the entity call that yields AgentResponse
|
||||
"""
|
||||
# Resolve session
|
||||
session_id = self._create_session_id(agent_name, thread)
|
||||
|
||||
# Create the entity ID
|
||||
entity_id = EntityInstanceId(
|
||||
entity=session_id.entity_name,
|
||||
key=session_id.key,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"[OrchestrationAgentExecutor] correlation_id: %s entity_id: %s session_id: %s",
|
||||
run_request.correlation_id,
|
||||
entity_id,
|
||||
session_id,
|
||||
)
|
||||
|
||||
# Branch based on wait_for_response
|
||||
if not run_request.wait_for_response:
|
||||
# Fire-and-forget mode: signal entity and return pre-completed task
|
||||
logger.info(
|
||||
"[OrchestrationAgentExecutor] Fire-and-forget mode: signaling entity (correlation: %s)",
|
||||
run_request.correlation_id,
|
||||
)
|
||||
self._context.signal_entity(entity_id, "run", run_request.to_dict())
|
||||
|
||||
# Create a pre-completed task with acceptance response
|
||||
acceptance_response = self._create_acceptance_response(run_request.correlation_id)
|
||||
entity_task: CompletableTask[AgentResponse] = CompletableTask()
|
||||
entity_task.complete(acceptance_response)
|
||||
else:
|
||||
# Blocking mode: call entity and wait for response
|
||||
entity_task = self._context.call_entity(entity_id, "run", run_request.to_dict()) # type: ignore
|
||||
|
||||
# Wrap in DurableAgentTask for response transformation
|
||||
return DurableAgentTask(
|
||||
entity_task=entity_task,
|
||||
response_format=run_request.response_format,
|
||||
correlation_id=run_request.correlation_id,
|
||||
)
|
||||
+172
-211
@@ -8,13 +8,14 @@ This module defines the request and response models used by the framework.
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import MutableMapping
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import azure.durable_functions as df
|
||||
from agent_framework import AgentThread, Role
|
||||
|
||||
from ._constants import REQUEST_RESPONSE_FORMAT_TEXT
|
||||
@@ -32,195 +33,6 @@ else:
|
||||
_PydanticBaseModel = _RuntimeBaseModel
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentSessionId:
|
||||
"""Represents an agent session ID, which is used to identify a long-running agent session.
|
||||
|
||||
Attributes:
|
||||
name: The name of the agent that owns the session (case-insensitive)
|
||||
key: The unique key of the agent session (case-sensitive)
|
||||
"""
|
||||
|
||||
name: str
|
||||
key: str
|
||||
|
||||
ENTITY_NAME_PREFIX: str = "dafx-"
|
||||
|
||||
@staticmethod
|
||||
def to_entity_name(name: str) -> str:
|
||||
"""Converts an agent name to an entity name by adding the DAFx prefix.
|
||||
|
||||
Args:
|
||||
name: The agent name
|
||||
|
||||
Returns:
|
||||
The entity name with the dafx- prefix
|
||||
"""
|
||||
return f"{AgentSessionId.ENTITY_NAME_PREFIX}{name}"
|
||||
|
||||
@staticmethod
|
||||
def with_random_key(name: str) -> AgentSessionId:
|
||||
"""Creates a new AgentSessionId with the specified name and a randomly generated key.
|
||||
|
||||
Args:
|
||||
name: The name of the agent that owns the session
|
||||
|
||||
Returns:
|
||||
A new AgentSessionId with the specified name and a random GUID key
|
||||
"""
|
||||
return AgentSessionId(name=name, key=uuid.uuid4().hex)
|
||||
|
||||
def to_entity_id(self) -> df.EntityId:
|
||||
"""Converts this AgentSessionId to a Durable Functions EntityId.
|
||||
|
||||
Returns:
|
||||
EntityId for use with Durable Functions APIs
|
||||
"""
|
||||
return df.EntityId(self.to_entity_name(self.name), self.key)
|
||||
|
||||
@staticmethod
|
||||
def from_entity_id(entity_id: df.EntityId) -> AgentSessionId:
|
||||
"""Creates an AgentSessionId from a Durable Functions EntityId.
|
||||
|
||||
Args:
|
||||
entity_id: The EntityId to convert
|
||||
|
||||
Returns:
|
||||
AgentSessionId instance
|
||||
|
||||
Raises:
|
||||
ValueError: If the entity ID does not have the expected prefix
|
||||
"""
|
||||
if not entity_id.name.startswith(AgentSessionId.ENTITY_NAME_PREFIX):
|
||||
raise ValueError(
|
||||
f"'{entity_id}' is not a valid agent session ID. "
|
||||
f"Expected entity name to start with '{AgentSessionId.ENTITY_NAME_PREFIX}'"
|
||||
)
|
||||
|
||||
agent_name = entity_id.name[len(AgentSessionId.ENTITY_NAME_PREFIX) :]
|
||||
return AgentSessionId(name=agent_name, key=entity_id.key)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Returns a string representation in the form @name@key."""
|
||||
return f"@{self.name}@{self.key}"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Returns a detailed string representation."""
|
||||
return f"AgentSessionId(name='{self.name}', key='{self.key}')"
|
||||
|
||||
@staticmethod
|
||||
def parse(session_id_string: str, agent_name: str | None = None) -> AgentSessionId:
|
||||
"""Parses a string representation of an agent session ID.
|
||||
|
||||
Args:
|
||||
session_id_string: A string in the form @name@key, or a plain key string
|
||||
when agent_name is provided.
|
||||
agent_name: Optional agent name to use instead of parsing from the string.
|
||||
If provided, only the key portion is extracted from session_id_string
|
||||
(for @name@key format) or the entire string is used as the key
|
||||
(for plain strings).
|
||||
|
||||
Returns:
|
||||
AgentSessionId instance
|
||||
|
||||
Raises:
|
||||
ValueError: If the string format is invalid and agent_name is not provided
|
||||
"""
|
||||
# Check if string is in @name@key format
|
||||
if session_id_string.startswith("@") and "@" in session_id_string[1:]:
|
||||
parts = session_id_string[1:].split("@", 1)
|
||||
name = agent_name if agent_name is not None else parts[0]
|
||||
return AgentSessionId(name=name, key=parts[1])
|
||||
|
||||
# Plain string format - only valid when agent_name is provided
|
||||
if agent_name is not None:
|
||||
return AgentSessionId(name=agent_name, key=session_id_string)
|
||||
|
||||
raise ValueError(f"Invalid agent session ID format: {session_id_string}")
|
||||
|
||||
|
||||
class DurableAgentThread(AgentThread):
|
||||
"""Durable agent thread that tracks the owning :class:`AgentSessionId`."""
|
||||
|
||||
_SERIALIZED_SESSION_ID_KEY = "durable_session_id"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
session_id: AgentSessionId | None = None,
|
||||
service_thread_id: str | None = None,
|
||||
message_store: Any = None,
|
||||
context_provider: Any = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
service_thread_id=service_thread_id,
|
||||
message_store=message_store,
|
||||
context_provider=context_provider,
|
||||
)
|
||||
self._session_id: AgentSessionId | None = session_id
|
||||
|
||||
@property
|
||||
def session_id(self) -> AgentSessionId | None:
|
||||
"""Returns the durable agent session identifier for this thread."""
|
||||
return self._session_id
|
||||
|
||||
def attach_session(self, session_id: AgentSessionId) -> None:
|
||||
"""Associates the thread with the provided :class:`AgentSessionId`."""
|
||||
self._session_id = session_id
|
||||
|
||||
@classmethod
|
||||
def from_session_id(
|
||||
cls,
|
||||
session_id: AgentSessionId,
|
||||
*,
|
||||
service_thread_id: str | None = None,
|
||||
message_store: Any = None,
|
||||
context_provider: Any = None,
|
||||
) -> DurableAgentThread:
|
||||
"""Creates a durable thread pre-associated with the supplied session ID."""
|
||||
return cls(
|
||||
session_id=session_id,
|
||||
service_thread_id=service_thread_id,
|
||||
message_store=message_store,
|
||||
context_provider=context_provider,
|
||||
)
|
||||
|
||||
async def serialize(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""Serializes thread state including the durable session identifier."""
|
||||
state = await super().serialize(**kwargs)
|
||||
if self._session_id is not None:
|
||||
state[self._SERIALIZED_SESSION_ID_KEY] = str(self._session_id)
|
||||
return state
|
||||
|
||||
@classmethod
|
||||
async def deserialize(
|
||||
cls,
|
||||
serialized_thread_state: MutableMapping[str, Any],
|
||||
*,
|
||||
message_store: Any = None,
|
||||
**kwargs: Any,
|
||||
) -> DurableAgentThread:
|
||||
"""Restores a durable thread, rehydrating the stored session identifier."""
|
||||
state_payload = dict(serialized_thread_state)
|
||||
session_id_value = state_payload.pop(cls._SERIALIZED_SESSION_ID_KEY, None)
|
||||
thread = await super().deserialize(
|
||||
state_payload,
|
||||
message_store=message_store,
|
||||
**kwargs,
|
||||
)
|
||||
if not isinstance(thread, DurableAgentThread):
|
||||
raise TypeError("Deserialized thread is not a DurableAgentThread instance")
|
||||
|
||||
if session_id_value is None:
|
||||
return thread
|
||||
|
||||
if not isinstance(session_id_value, str):
|
||||
raise ValueError("durable_session_id must be a string when present in serialized state")
|
||||
|
||||
thread.attach_session(AgentSessionId.parse(session_id_value))
|
||||
return thread
|
||||
|
||||
|
||||
def serialize_response_format(response_format: type[BaseModel] | None) -> Any:
|
||||
"""Serialize response format for transport across durable function boundaries."""
|
||||
if response_format is None:
|
||||
@@ -292,43 +104,48 @@ class RunRequest:
|
||||
role: The role of the message sender (user, system, or assistant)
|
||||
response_format: Optional Pydantic BaseModel type describing the structured response format
|
||||
enable_tool_calls: Whether to enable tool calls for this request
|
||||
thread_id: Optional thread ID for tracking
|
||||
correlation_id: Optional correlation ID for tracking the response to this specific request
|
||||
wait_for_response: If True (default), caller will wait for agent response. If False,
|
||||
returns immediately after signaling (fire-and-forget mode)
|
||||
correlation_id: Correlation ID for tracking the response to this specific request
|
||||
created_at: Optional timestamp when the request was created
|
||||
orchestration_id: Optional ID of the orchestration that initiated this request
|
||||
options: Optional options dictionary forwarded to the agent
|
||||
"""
|
||||
|
||||
message: str
|
||||
request_response_format: str
|
||||
correlation_id: str
|
||||
role: Role = Role.USER
|
||||
response_format: type[BaseModel] | None = None
|
||||
enable_tool_calls: bool = True
|
||||
thread_id: str | None = None
|
||||
correlation_id: str | None = None
|
||||
created_at: str | None = None
|
||||
wait_for_response: bool = True
|
||||
created_at: datetime | None = None
|
||||
orchestration_id: str | None = None
|
||||
options: dict[str, Any] = field(default_factory=lambda: {})
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
correlation_id: str,
|
||||
request_response_format: str = REQUEST_RESPONSE_FORMAT_TEXT,
|
||||
role: Role | str | None = Role.USER,
|
||||
response_format: type[BaseModel] | None = None,
|
||||
enable_tool_calls: bool = True,
|
||||
thread_id: str | None = None,
|
||||
correlation_id: str | None = None,
|
||||
created_at: str | None = None,
|
||||
wait_for_response: bool = True,
|
||||
created_at: datetime | None = None,
|
||||
orchestration_id: str | None = None,
|
||||
options: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
self.message = message
|
||||
self.correlation_id = correlation_id
|
||||
self.role = self.coerce_role(role)
|
||||
self.response_format = response_format
|
||||
self.request_response_format = request_response_format
|
||||
self.enable_tool_calls = enable_tool_calls
|
||||
self.thread_id = thread_id
|
||||
self.correlation_id = correlation_id
|
||||
self.created_at = created_at
|
||||
self.wait_for_response = wait_for_response
|
||||
self.created_at = created_at if created_at is not None else datetime.now(tz=timezone.utc)
|
||||
self.orchestration_id = orchestration_id
|
||||
self.options = options if options is not None else {}
|
||||
|
||||
@staticmethod
|
||||
def coerce_role(value: Role | str | None) -> Role:
|
||||
@@ -347,33 +164,177 @@ class RunRequest:
|
||||
result = {
|
||||
"message": self.message,
|
||||
"enable_tool_calls": self.enable_tool_calls,
|
||||
"wait_for_response": self.wait_for_response,
|
||||
"role": self.role.value,
|
||||
"request_response_format": self.request_response_format,
|
||||
"correlationId": self.correlation_id,
|
||||
"options": self.options,
|
||||
}
|
||||
if self.response_format:
|
||||
result["response_format"] = serialize_response_format(self.response_format)
|
||||
if self.thread_id:
|
||||
result["thread_id"] = self.thread_id
|
||||
if self.correlation_id:
|
||||
result["correlationId"] = self.correlation_id
|
||||
if self.created_at:
|
||||
result["created_at"] = self.created_at
|
||||
result["created_at"] = self.created_at.isoformat()
|
||||
if self.orchestration_id:
|
||||
result["orchestrationId"] = self.orchestration_id
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: str) -> RunRequest:
|
||||
"""Create RunRequest from JSON string."""
|
||||
try:
|
||||
dict_data = json.loads(data)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError("The durable agent state is not valid JSON.") from e
|
||||
|
||||
return cls.from_dict(dict_data)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> RunRequest:
|
||||
"""Create RunRequest from dictionary."""
|
||||
created_at = data.get("created_at")
|
||||
if isinstance(created_at, str):
|
||||
try:
|
||||
created_at = datetime.fromisoformat(created_at)
|
||||
except ValueError:
|
||||
created_at = None
|
||||
|
||||
correlation_id = data.get("correlationId")
|
||||
if not correlation_id:
|
||||
raise ValueError("correlationId is required in RunRequest data")
|
||||
|
||||
options = data.get("options")
|
||||
|
||||
return cls(
|
||||
message=data.get("message", ""),
|
||||
correlation_id=correlation_id,
|
||||
request_response_format=data.get("request_response_format", REQUEST_RESPONSE_FORMAT_TEXT),
|
||||
role=cls.coerce_role(data.get("role")),
|
||||
response_format=_deserialize_response_format(data.get("response_format")),
|
||||
wait_for_response=data.get("wait_for_response", True),
|
||||
enable_tool_calls=data.get("enable_tool_calls", True),
|
||||
thread_id=data.get("thread_id"),
|
||||
correlation_id=data.get("correlationId"),
|
||||
created_at=data.get("created_at"),
|
||||
created_at=created_at,
|
||||
orchestration_id=data.get("orchestrationId"),
|
||||
options=cast(dict[str, Any], options) if isinstance(options, dict) else {},
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentSessionId:
|
||||
"""Represents an agent session identifier (name + key)."""
|
||||
|
||||
name: str
|
||||
key: str
|
||||
|
||||
ENTITY_NAME_PREFIX: str = "dafx-"
|
||||
|
||||
@staticmethod
|
||||
def to_entity_name(name: str) -> str:
|
||||
return f"{AgentSessionId.ENTITY_NAME_PREFIX}{name}"
|
||||
|
||||
@staticmethod
|
||||
def with_random_key(name: str) -> AgentSessionId:
|
||||
return AgentSessionId(name=name, key=uuid.uuid4().hex)
|
||||
|
||||
@property
|
||||
def entity_name(self) -> str:
|
||||
return self.to_entity_name(self.name)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"@{self.name}@{self.key}"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"AgentSessionId(name='{self.name}', key='{self.key}')"
|
||||
|
||||
@staticmethod
|
||||
def parse(session_id_string: str, agent_name: str | None = None) -> AgentSessionId:
|
||||
"""Parses a string representation of an agent session ID.
|
||||
|
||||
Args:
|
||||
session_id_string: A string in the form @name@key, or a plain key string
|
||||
when agent_name is provided.
|
||||
agent_name: Optional agent name to use instead of parsing from the string.
|
||||
If provided, only the key portion is extracted from session_id_string
|
||||
(for @name@key format) or the entire string is used as the key
|
||||
(for plain strings).
|
||||
|
||||
Returns:
|
||||
AgentSessionId instance
|
||||
|
||||
Raises:
|
||||
ValueError: If the string format is invalid and agent_name is not provided
|
||||
"""
|
||||
# Check if string is in @name@key format
|
||||
if session_id_string.startswith("@") and "@" in session_id_string[1:]:
|
||||
parts = session_id_string[1:].split("@", 1)
|
||||
name = agent_name if agent_name is not None else parts[0]
|
||||
return AgentSessionId(name=name, key=parts[1])
|
||||
|
||||
# Plain string format - only valid when agent_name is provided
|
||||
if agent_name is not None:
|
||||
return AgentSessionId(name=agent_name, key=session_id_string)
|
||||
|
||||
raise ValueError(f"Invalid agent session ID format: {session_id_string}")
|
||||
|
||||
|
||||
class DurableAgentThread(AgentThread):
|
||||
"""Durable agent thread that tracks the owning :class:`AgentSessionId`."""
|
||||
|
||||
_SERIALIZED_SESSION_ID_KEY = "durable_session_id"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
session_id: AgentSessionId | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._session_id: AgentSessionId | None = session_id
|
||||
|
||||
@property
|
||||
def session_id(self) -> AgentSessionId | None:
|
||||
return self._session_id
|
||||
|
||||
@session_id.setter
|
||||
def session_id(self, value: AgentSessionId | None) -> None:
|
||||
self._session_id = value
|
||||
|
||||
@classmethod
|
||||
def from_session_id(
|
||||
cls,
|
||||
session_id: AgentSessionId,
|
||||
**kwargs: Any,
|
||||
) -> DurableAgentThread:
|
||||
return cls(session_id=session_id, **kwargs)
|
||||
|
||||
async def serialize(self, **kwargs: Any) -> dict[str, Any]:
|
||||
state = await super().serialize(**kwargs)
|
||||
if self._session_id is not None:
|
||||
state[self._SERIALIZED_SESSION_ID_KEY] = str(self._session_id)
|
||||
return state
|
||||
|
||||
@classmethod
|
||||
async def deserialize(
|
||||
cls,
|
||||
serialized_thread_state: MutableMapping[str, Any],
|
||||
*,
|
||||
message_store: Any = None,
|
||||
**kwargs: Any,
|
||||
) -> DurableAgentThread:
|
||||
state_payload = dict(serialized_thread_state)
|
||||
session_id_value = state_payload.pop(cls._SERIALIZED_SESSION_ID_KEY, None)
|
||||
thread = await super().deserialize(
|
||||
state_payload,
|
||||
message_store=message_store,
|
||||
**kwargs,
|
||||
)
|
||||
if not isinstance(thread, DurableAgentThread):
|
||||
raise TypeError("Deserialized thread is not a DurableAgentThread instance")
|
||||
|
||||
if session_id_value is None:
|
||||
return thread
|
||||
|
||||
if not isinstance(session_id_value, str):
|
||||
raise ValueError("durable_session_id must be a string when present in serialized state")
|
||||
|
||||
thread.session_id = AgentSessionId.parse(session_id_value)
|
||||
return thread
|
||||
@@ -0,0 +1,75 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Orchestration context wrapper for Durable Task Agent Framework.
|
||||
|
||||
This module provides the DurableAIAgentOrchestrationContext class for use inside
|
||||
orchestration functions to interact with durable agents.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from agent_framework import get_logger
|
||||
from durabletask.task import OrchestrationContext
|
||||
|
||||
from ._executors import DurableAgentTask, OrchestrationAgentExecutor
|
||||
from ._shim import DurableAgentProvider, DurableAIAgent
|
||||
|
||||
logger = get_logger("agent_framework.durabletask.orchestration_context")
|
||||
|
||||
|
||||
class DurableAIAgentOrchestrationContext(DurableAgentProvider[DurableAgentTask]):
|
||||
"""Orchestration context wrapper for interacting with durable agents internally.
|
||||
|
||||
This class wraps a durabletask OrchestrationContext and provides a convenient
|
||||
interface for retrieving and executing durable agents from within orchestration
|
||||
functions.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from durabletask import Orchestration
|
||||
from agent_framework.azure import DurableAIAgentOrchestrationContext
|
||||
|
||||
|
||||
def my_orchestration(context: OrchestrationContext):
|
||||
# Wrap the context
|
||||
agent_context = DurableAIAgentOrchestrationContext(context)
|
||||
|
||||
# Get an agent reference
|
||||
agent = agent_context.get_agent("assistant")
|
||||
|
||||
# Run the agent (returns a Task to be yielded)
|
||||
result = yield agent.run("Hello, how are you?")
|
||||
|
||||
return result.text
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, context: OrchestrationContext):
|
||||
"""Initialize the orchestration context wrapper.
|
||||
|
||||
Args:
|
||||
context: The durabletask orchestration context to wrap
|
||||
"""
|
||||
self._context = context
|
||||
self._executor = OrchestrationAgentExecutor(self._context)
|
||||
logger.debug("[DurableAIAgentOrchestrationContext] Initialized")
|
||||
|
||||
def get_agent(self, agent_name: str) -> DurableAIAgent[DurableAgentTask]:
|
||||
"""Retrieve a DurableAIAgent shim for the specified agent.
|
||||
|
||||
This method returns a proxy object that can be used to execute the agent
|
||||
within an orchestration. The agent's run() method will return a Task that
|
||||
must be yielded.
|
||||
|
||||
Args:
|
||||
agent_name: Name of the agent to retrieve (without the dafx- prefix)
|
||||
|
||||
Returns:
|
||||
DurableAIAgent instance that can be used to run the agent
|
||||
|
||||
Note:
|
||||
Validation is deferred to execution time. The entity must be registered
|
||||
on a worker with the name f"dafx-{agent_name}".
|
||||
"""
|
||||
logger.debug("[DurableAIAgentOrchestrationContext] Creating agent proxy for: %s", agent_name)
|
||||
return DurableAIAgent(self._executor, agent_name)
|
||||
@@ -0,0 +1,72 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Shared utilities for handling AgentResponse parsing and validation."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import AgentResponse, get_logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = get_logger("agent_framework.durabletask.response_utils")
|
||||
|
||||
|
||||
def load_agent_response(agent_response: AgentResponse | dict[str, Any] | None) -> AgentResponse:
|
||||
"""Convert raw payloads into AgentResponse instance.
|
||||
|
||||
Args:
|
||||
agent_response: The response to convert, can be an AgentResponse, dict, or None
|
||||
|
||||
Returns:
|
||||
AgentResponse: The converted response object
|
||||
|
||||
Raises:
|
||||
ValueError: If agent_response is None
|
||||
TypeError: If agent_response is an unsupported type
|
||||
"""
|
||||
if agent_response is None:
|
||||
raise ValueError("agent_response cannot be None")
|
||||
|
||||
logger.debug("[load_agent_response] Loading agent response of type: %s", type(agent_response))
|
||||
|
||||
if isinstance(agent_response, AgentResponse):
|
||||
return agent_response
|
||||
if isinstance(agent_response, dict):
|
||||
logger.debug("[load_agent_response] Converting dict payload using AgentResponse.from_dict")
|
||||
return AgentResponse.from_dict(agent_response)
|
||||
|
||||
raise TypeError(f"Unsupported type for agent_response: {type(agent_response)}")
|
||||
|
||||
|
||||
def ensure_response_format(
|
||||
response_format: type[BaseModel] | None,
|
||||
correlation_id: str,
|
||||
response: AgentResponse,
|
||||
) -> None:
|
||||
"""Ensure the AgentResponse value is parsed into the expected response_format.
|
||||
|
||||
This function modifies the response in-place by parsing its value attribute
|
||||
into the specified Pydantic model format.
|
||||
|
||||
Args:
|
||||
response_format: Optional Pydantic model class to parse the response value into
|
||||
correlation_id: Correlation ID for logging purposes
|
||||
response: The AgentResponse object to validate and parse
|
||||
|
||||
Raises:
|
||||
ValueError: If response_format is specified but response.value cannot be parsed
|
||||
"""
|
||||
if response_format is not None and not isinstance(response.value, response_format):
|
||||
response.try_parse_value(response_format)
|
||||
|
||||
# Validate that parsing succeeded
|
||||
if not isinstance(response.value, response_format):
|
||||
raise ValueError(
|
||||
f"Response value could not be parsed into required format {response_format.__name__} "
|
||||
f"for correlation_id {correlation_id}"
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"[ensure_response_format] Loaded AgentResponse.value for correlation_id %s with type: %s",
|
||||
correlation_id,
|
||||
type(response.value).__name__,
|
||||
)
|
||||
@@ -0,0 +1,192 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Durable Agent Shim for Durable Task Framework.
|
||||
|
||||
This module provides the DurableAIAgent shim that implements AgentProtocol
|
||||
and provides a consistent interface for both Client and Orchestration contexts.
|
||||
The actual execution is delegated to the context-specific providers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from agent_framework import AgentProtocol, AgentResponseUpdate, AgentThread, ChatMessage
|
||||
|
||||
from ._executors import DurableAgentExecutor
|
||||
from ._models import DurableAgentThread
|
||||
|
||||
# TypeVar for the task type returned by executors
|
||||
# Covariant because TaskT only appears in return positions (output)
|
||||
TaskT = TypeVar("TaskT", covariant=True)
|
||||
|
||||
|
||||
class DurableAgentProvider(ABC, Generic[TaskT]):
|
||||
"""Abstract provider for constructing durable agent proxies.
|
||||
|
||||
Implemented by context-specific wrappers (client/orchestration) to return a
|
||||
`DurableAIAgent` shim backed by their respective `DurableAgentExecutor`
|
||||
implementation, ensuring a consistent `get_agent` entry point regardless of
|
||||
execution context.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_agent(self, agent_name: str) -> DurableAIAgent[TaskT]:
|
||||
"""Retrieve a DurableAIAgent shim for the specified agent.
|
||||
|
||||
Args:
|
||||
agent_name: Name of the agent to retrieve
|
||||
|
||||
Returns:
|
||||
DurableAIAgent instance that can be used to run the agent
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Must be implemented by subclasses
|
||||
"""
|
||||
raise NotImplementedError("Subclasses must implement get_agent()")
|
||||
|
||||
|
||||
class DurableAIAgent(AgentProtocol, Generic[TaskT]):
|
||||
"""A durable agent proxy that delegates execution to the provider.
|
||||
|
||||
This class implements AgentProtocol but with one critical difference:
|
||||
- AgentProtocol.run() returns a Coroutine (async, must await)
|
||||
- DurableAIAgent.run() returns TaskT (sync Task object - must yield
|
||||
or the AgentResponse directly in the case of TaskHubGrpcClient)
|
||||
|
||||
This represents fundamentally different execution models but maintains the same
|
||||
interface contract for all other properties and methods.
|
||||
|
||||
The underlying provider determines how execution occurs (entity calls, HTTP requests, etc.)
|
||||
and what type of Task object is returned.
|
||||
|
||||
Type Parameters:
|
||||
TaskT: The task type returned by this agent (e.g., AgentResponse, DurableAgentTask, AgentTask)
|
||||
"""
|
||||
|
||||
def __init__(self, executor: DurableAgentExecutor[TaskT], name: str, *, agent_id: str | None = None):
|
||||
"""Initialize the shim with a provider and agent name.
|
||||
|
||||
Args:
|
||||
executor: The execution provider (Client or OrchestrationContext)
|
||||
name: The name of the agent to execute
|
||||
agent_id: Optional unique identifier for the agent (defaults to name)
|
||||
"""
|
||||
self._executor = executor
|
||||
self._name = name
|
||||
self._id = agent_id if agent_id is not None else name
|
||||
self._display_name = name
|
||||
self._description = f"Durable agent proxy for {name}"
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
"""Get the unique identifier for this agent."""
|
||||
return self._id
|
||||
|
||||
@property
|
||||
def name(self) -> str | None:
|
||||
"""Get the name of the agent."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
"""Get the display name of the agent."""
|
||||
return self._display_name
|
||||
|
||||
@property
|
||||
def description(self) -> str | None:
|
||||
"""Get the description of the agent."""
|
||||
return self._description
|
||||
|
||||
def run( # pyright: ignore[reportIncompatibleMethodOverride]
|
||||
self,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
options: dict[str, Any] | None = None,
|
||||
) -> TaskT:
|
||||
"""Execute the agent via the injected provider.
|
||||
|
||||
Args:
|
||||
messages: The message(s) to send to the agent
|
||||
thread: Optional agent thread for conversation context
|
||||
options: Optional options dictionary. Supported keys include
|
||||
``response_format``, ``enable_tool_calls``, and ``wait_for_response``.
|
||||
Additional keys are forwarded to the agent execution.
|
||||
|
||||
Note:
|
||||
This method overrides AgentProtocol.run() with a different return type:
|
||||
- AgentProtocol.run() returns Coroutine[Any, Any, AgentResponse] (async)
|
||||
- DurableAIAgent.run() returns TaskT (Task object for yielding)
|
||||
|
||||
This is intentional to support orchestration contexts that use yield patterns
|
||||
instead of async/await patterns.
|
||||
|
||||
Returns:
|
||||
TaskT: The task type specific to the executor
|
||||
|
||||
Raises:
|
||||
ValueError: If wait_for_response=False is used in an unsupported context
|
||||
"""
|
||||
message_str = self._normalize_messages(messages)
|
||||
|
||||
run_request = self._executor.get_run_request(
|
||||
message=message_str,
|
||||
options=options,
|
||||
)
|
||||
|
||||
return self._executor.run_durable_agent(
|
||||
agent_name=self._name,
|
||||
run_request=run_request,
|
||||
thread=thread,
|
||||
)
|
||||
|
||||
def run_stream(
|
||||
self,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[AgentResponseUpdate]:
|
||||
"""Run the agent with streaming (not supported for durable agents).
|
||||
|
||||
Args:
|
||||
messages: The message(s) to send to the agent
|
||||
thread: Optional agent thread for conversation context
|
||||
**kwargs: Additional arguments
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Streaming is not supported for durable agents
|
||||
"""
|
||||
raise NotImplementedError("Streaming is not supported for durable agents")
|
||||
|
||||
def get_new_thread(self, **kwargs: Any) -> DurableAgentThread:
|
||||
"""Create a new agent thread via the provider."""
|
||||
return self._executor.get_new_thread(self._name, **kwargs)
|
||||
|
||||
def _normalize_messages(self, messages: str | ChatMessage | list[str] | list[ChatMessage] | None) -> str:
|
||||
"""Convert supported message inputs to a single string.
|
||||
|
||||
Args:
|
||||
messages: The messages to normalize
|
||||
|
||||
Returns:
|
||||
A single string representation of the messages
|
||||
"""
|
||||
if messages is None:
|
||||
return ""
|
||||
if isinstance(messages, str):
|
||||
return messages
|
||||
if isinstance(messages, ChatMessage):
|
||||
return messages.text or ""
|
||||
if isinstance(messages, list):
|
||||
if not messages:
|
||||
return ""
|
||||
first_item = messages[0]
|
||||
if isinstance(first_item, str):
|
||||
return "\n".join(messages) # type: ignore[arg-type]
|
||||
# List of ChatMessage
|
||||
return "\n".join([msg.text or "" for msg in messages]) # type: ignore[union-attr]
|
||||
return ""
|
||||
@@ -0,0 +1,218 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Worker wrapper for Durable Task Agent Framework.
|
||||
|
||||
This module provides the DurableAIAgentWorker class that wraps a durabletask worker
|
||||
and enables registration of agents as durable entities.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import AgentProtocol, get_logger
|
||||
from durabletask.worker import TaskHubGrpcWorker
|
||||
|
||||
from ._callbacks import AgentResponseCallbackProtocol
|
||||
from ._entities import AgentEntity, DurableTaskEntityStateProvider
|
||||
|
||||
logger = get_logger("agent_framework.durabletask.worker")
|
||||
|
||||
|
||||
class DurableAIAgentWorker:
|
||||
"""Wrapper for durabletask worker that enables agent registration.
|
||||
|
||||
This class wraps an existing TaskHubGrpcWorker instance and provides
|
||||
a convenient interface for registering agents as durable entities.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from durabletask import TaskHubGrpcWorker
|
||||
from agent_framework import ChatAgent
|
||||
from agent_framework.azure import DurableAIAgentWorker
|
||||
|
||||
# Create the underlying worker
|
||||
worker = TaskHubGrpcWorker(host_address="localhost:4001")
|
||||
|
||||
# Wrap it with the agent worker
|
||||
agent_worker = DurableAIAgentWorker(worker)
|
||||
|
||||
# Register agents
|
||||
my_agent = ChatAgent(chat_client=client, name="assistant")
|
||||
agent_worker.add_agent(my_agent)
|
||||
|
||||
# Start the worker
|
||||
worker.start()
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
worker: TaskHubGrpcWorker,
|
||||
callback: AgentResponseCallbackProtocol | None = None,
|
||||
):
|
||||
"""Initialize the worker wrapper.
|
||||
|
||||
Args:
|
||||
worker: The durabletask worker instance to wrap
|
||||
callback: Optional callback for agent response notifications
|
||||
"""
|
||||
self._worker = worker
|
||||
self._callback = callback
|
||||
self._registered_agents: dict[str, AgentProtocol] = {}
|
||||
logger.debug("[DurableAIAgentWorker] Initialized with worker type: %s", type(worker).__name__)
|
||||
|
||||
def add_agent(
|
||||
self,
|
||||
agent: AgentProtocol,
|
||||
callback: AgentResponseCallbackProtocol | None = None,
|
||||
) -> None:
|
||||
"""Register an agent with the worker.
|
||||
|
||||
This method creates a durable entity class for the agent and registers
|
||||
it with the underlying durabletask worker. The entity will be accessible
|
||||
by the name "dafx-{agent_name}".
|
||||
|
||||
Args:
|
||||
agent: The agent to register (must have a name)
|
||||
callback: Optional callback for this specific agent (overrides worker-level callback)
|
||||
|
||||
Raises:
|
||||
ValueError: If the agent doesn't have a name or is already registered
|
||||
"""
|
||||
agent_name = agent.name
|
||||
if not agent_name:
|
||||
raise ValueError("Agent must have a name to be registered")
|
||||
|
||||
if agent_name in self._registered_agents:
|
||||
raise ValueError(f"Agent '{agent_name}' is already registered")
|
||||
|
||||
logger.info("[DurableAIAgentWorker] Registering agent: %s as entity: dafx-%s", agent_name, agent_name)
|
||||
|
||||
# Store the agent reference
|
||||
self._registered_agents[agent_name] = agent
|
||||
|
||||
# Use agent-specific callback if provided, otherwise use worker-level callback
|
||||
effective_callback = callback or self._callback
|
||||
|
||||
# Create a configured entity class using the factory
|
||||
entity_class = self.__create_agent_entity(agent, effective_callback)
|
||||
|
||||
# Register the entity class with the worker
|
||||
# The worker.add_entity method takes a class
|
||||
entity_registered: str = self._worker.add_entity(entity_class) # pyright: ignore[reportUnknownMemberType]
|
||||
|
||||
logger.debug(
|
||||
"[DurableAIAgentWorker] Successfully registered entity class %s for agent: %s",
|
||||
entity_registered,
|
||||
agent_name,
|
||||
)
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the worker to begin processing tasks.
|
||||
|
||||
Note:
|
||||
This method delegates to the underlying worker's start method.
|
||||
The worker will block until stopped.
|
||||
"""
|
||||
logger.info("[DurableAIAgentWorker] Starting worker with %d registered agents", len(self._registered_agents))
|
||||
self._worker.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the worker gracefully.
|
||||
|
||||
Note:
|
||||
This method delegates to the underlying worker's stop method.
|
||||
"""
|
||||
logger.info("[DurableAIAgentWorker] Stopping worker")
|
||||
self._worker.stop()
|
||||
|
||||
@property
|
||||
def registered_agent_names(self) -> list[str]:
|
||||
"""Get the names of all registered agents.
|
||||
|
||||
Returns:
|
||||
List of agent names (without the dafx- prefix)
|
||||
"""
|
||||
return list(self._registered_agents.keys())
|
||||
|
||||
def __create_agent_entity(
|
||||
self,
|
||||
agent: AgentProtocol,
|
||||
callback: AgentResponseCallbackProtocol | None = None,
|
||||
) -> type[DurableTaskEntityStateProvider]:
|
||||
"""Factory function to create a DurableEntity class configured with an agent.
|
||||
|
||||
This factory creates a new class that combines the entity state provider
|
||||
with the agent execution logic. Each agent gets its own entity class.
|
||||
|
||||
Args:
|
||||
agent: The agent instance to wrap
|
||||
callback: Optional callback for agent responses
|
||||
|
||||
Returns:
|
||||
A new DurableEntity subclass configured for this agent
|
||||
"""
|
||||
agent_name = agent.name or type(agent).__name__
|
||||
entity_name = f"dafx-{agent_name}"
|
||||
|
||||
class ConfiguredAgentEntity(DurableTaskEntityStateProvider):
|
||||
"""Durable entity configured with a specific agent instance."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
# Create the AgentEntity with this state provider
|
||||
self._agent_entity = AgentEntity(
|
||||
agent=agent,
|
||||
callback=callback,
|
||||
state_provider=self,
|
||||
)
|
||||
logger.debug(
|
||||
"[ConfiguredAgentEntity] Initialized entity for agent: %s (entity name: %s)",
|
||||
agent_name,
|
||||
entity_name,
|
||||
)
|
||||
|
||||
def run(self, request: Any) -> Any:
|
||||
"""Handle run requests from clients or orchestrations.
|
||||
|
||||
Args:
|
||||
request: RunRequest as dict or string
|
||||
|
||||
Returns:
|
||||
AgentResponse as dict
|
||||
"""
|
||||
logger.debug("[ConfiguredAgentEntity.run] Executing agent: %s", agent_name)
|
||||
# Get or create event loop for async execution
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
# Run the async agent execution synchronously
|
||||
if loop.is_running():
|
||||
# If loop is already running (shouldn't happen in entity context),
|
||||
# create a temporary loop
|
||||
temp_loop = asyncio.new_event_loop()
|
||||
try:
|
||||
response = temp_loop.run_until_complete(self._agent_entity.run(request))
|
||||
finally:
|
||||
temp_loop.close()
|
||||
else:
|
||||
response = loop.run_until_complete(self._agent_entity.run(request))
|
||||
|
||||
return response.to_dict()
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the agent's conversation history."""
|
||||
logger.debug("[ConfiguredAgentEntity.reset] Resetting agent: %s", agent_name)
|
||||
self._agent_entity.reset()
|
||||
|
||||
# Set the entity name to match the prefixed agent name
|
||||
# This is used by durabletask to register the entity
|
||||
ConfiguredAgentEntity.__name__ = entity_name
|
||||
ConfiguredAgentEntity.__qualname__ = entity_name
|
||||
|
||||
return ConfiguredAgentEntity
|
||||
@@ -0,0 +1,101 @@
|
||||
[project]
|
||||
name = "agent-framework-durabletask"
|
||||
description = "Durable Task integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "0.0.1b260113"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
|
||||
urls.issues = "https://github.com/microsoft/agent-framework/issues"
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core",
|
||||
"durabletask>=1.3.0",
|
||||
"durabletask-azuremanaged>=1.3.0"
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"types-python-dateutil>=2.9.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
prerelease = "if-necessary-or-explicit"
|
||||
environments = [
|
||||
"sys_platform == 'darwin'",
|
||||
"sys_platform == 'linux'",
|
||||
"sys_platform == 'win32'"
|
||||
]
|
||||
|
||||
[tool.uv-dynamic-versioning]
|
||||
fallback-version = "0.0.0"
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = 'tests'
|
||||
addopts = "-ra -q -r fEX"
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
filterwarnings = [
|
||||
"ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*"
|
||||
]
|
||||
timeout = 120
|
||||
markers = [
|
||||
"integration: marks tests as integration tests",
|
||||
"integration_test: marks tests as integration tests (alternative marker)",
|
||||
"sample: marks tests as sample tests",
|
||||
"requires_azure_openai: marks tests that require Azure OpenAI",
|
||||
"requires_dts: marks tests that require Durable Task Scheduler",
|
||||
"requires_redis: marks tests that require Redis"
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../pyproject.toml"
|
||||
|
||||
[tool.coverage.run]
|
||||
omit = [
|
||||
"**/__init__.py"
|
||||
]
|
||||
|
||||
[tool.pyright]
|
||||
extends = "../../pyproject.toml"
|
||||
|
||||
[tool.mypy]
|
||||
plugins = ['pydantic.mypy']
|
||||
strict = true
|
||||
python_version = "3.10"
|
||||
ignore_missing_imports = true
|
||||
disallow_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
check_untyped_defs = true
|
||||
warn_return_any = true
|
||||
show_error_codes = true
|
||||
warn_unused_ignores = false
|
||||
disallow_incomplete_defs = true
|
||||
disallow_untyped_decorators = true
|
||||
|
||||
[tool.bandit]
|
||||
targets = ["agent_framework_durabletask"]
|
||||
exclude_dirs = ["tests"]
|
||||
|
||||
[tool.poe]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
[tool.poe.tasks]
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_durabletask"
|
||||
test = "pytest --cov=agent_framework_durabletask --cov-report=term-missing:skip-covered tests"
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
build-backend = "flit_core.buildapi"
|
||||
@@ -0,0 +1,17 @@
|
||||
# Azure OpenAI Configuration
|
||||
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=your-deployment-name
|
||||
# Optional: Use Azure CLI authentication if not provided
|
||||
# AZURE_OPENAI_API_KEY=your-api-key
|
||||
|
||||
# Durable Task Scheduler Configuration
|
||||
ENDPOINT=http://localhost:8080
|
||||
TASKHUB=default
|
||||
|
||||
# Redis Configuration (for streaming tests)
|
||||
REDIS_CONNECTION_STRING=redis://localhost:6379
|
||||
REDIS_STREAM_TTL_MINUTES=10
|
||||
|
||||
# Integration Test Control
|
||||
# Set to 'true' to enable integration tests
|
||||
RUN_INTEGRATION_TESTS=true
|
||||
@@ -0,0 +1,111 @@
|
||||
# Sample Integration Tests
|
||||
|
||||
Integration tests that validate the Durable Agent Framework samples by running them against a Durable Task Scheduler (DTS) instance.
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Create `.env` file
|
||||
|
||||
Copy `.env.example` to `.env` and fill in your Azure credentials:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Required variables:
|
||||
- `AZURE_OPENAI_ENDPOINT`
|
||||
- `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`
|
||||
- `AZURE_OPENAI_API_KEY` (optional if using Azure CLI authentication)
|
||||
- `RUN_INTEGRATION_TESTS` (set to `true`)
|
||||
- `ENDPOINT` (default: http://localhost:8080)
|
||||
- `TASKHUB` (default: default)
|
||||
|
||||
Optional variables (for streaming tests):
|
||||
- `REDIS_CONNECTION_STRING` (default: redis://localhost:6379)
|
||||
- `REDIS_STREAM_TTL_MINUTES` (default: 10)
|
||||
|
||||
### 2. Start required services
|
||||
|
||||
**Durable Task Scheduler:**
|
||||
```bash
|
||||
docker run -d --name dts-emulator -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest
|
||||
```
|
||||
- Port 8080: gRPC endpoint (used by tests)
|
||||
- Port 8082: Web dashboard (optional, for monitoring)
|
||||
|
||||
**Redis (for streaming tests):**
|
||||
```bash
|
||||
docker run -d --name redis -p 6379:6379 redis:latest
|
||||
```
|
||||
- Port 6379: Redis server endpoint
|
||||
|
||||
## Running Tests
|
||||
|
||||
The tests automatically start and stop worker processes for each sample.
|
||||
|
||||
### Run all sample tests
|
||||
```bash
|
||||
uv run pytest packages/durabletask/tests/integration_tests -v
|
||||
```
|
||||
|
||||
### Run specific sample
|
||||
```bash
|
||||
uv run pytest packages/durabletask/tests/integration_tests/test_01_single_agent.py -v
|
||||
```
|
||||
|
||||
### Run with verbose output
|
||||
```bash
|
||||
uv run pytest packages/durabletask/tests/integration_tests -sv
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
Each test file uses pytest markers to automatically configure and start the worker process:
|
||||
|
||||
```python
|
||||
pytestmark = [
|
||||
pytest.mark.sample("03_single_agent_streaming"),
|
||||
pytest.mark.integration_test,
|
||||
pytest.mark.requires_azure_openai,
|
||||
pytest.mark.requires_dts,
|
||||
pytest.mark.requires_redis,
|
||||
]
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Tests are skipped:**
|
||||
Ensure `RUN_INTEGRATION_TESTS=true` is set in your `.env` file.
|
||||
|
||||
**DTS connection failed:**
|
||||
Check that the DTS emulator container is running: `docker ps | grep dts-emulator`
|
||||
|
||||
**Redis connection failed:**
|
||||
Check that Redis is running: `docker ps | grep redis`
|
||||
|
||||
**Missing environment variables:**
|
||||
Ensure your `.env` file contains all required variables from `.env.example`.
|
||||
|
||||
**Tests timeout:**
|
||||
Check that Azure OpenAI credentials are valid and the service is accessible.
|
||||
|
||||
If you see "DTS emulator is not available":
|
||||
- Ensure Docker container is running: `docker ps | grep dts-emulator`
|
||||
- Check port 8080 is not in use by another process
|
||||
- Restart the container if needed
|
||||
|
||||
### Azure OpenAI Errors
|
||||
|
||||
If you see authentication or deployment errors:
|
||||
- Verify your `AZURE_OPENAI_ENDPOINT` is correct
|
||||
- Confirm `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME` matches your deployment
|
||||
- If using API key, check `AZURE_OPENAI_API_KEY` is valid
|
||||
- If using Azure CLI, ensure you're logged in: `az login`
|
||||
|
||||
## CI/CD
|
||||
|
||||
For automated testing in CI/CD pipelines:
|
||||
|
||||
1. Use Docker Compose to start DTS emulator
|
||||
2. Set environment variables via CI/CD secrets
|
||||
3. Run tests with appropriate markers: `pytest -m integration_test`
|
||||
@@ -0,0 +1,234 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
"""Pytest configuration and fixtures for durabletask integration tests."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
import redis.asyncio as aioredis
|
||||
from dotenv import load_dotenv
|
||||
from durabletask.azuremanaged.client import DurableTaskSchedulerClient
|
||||
|
||||
# Add the integration_tests directory to the path so testutils can be imported
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv(Path(__file__).parent / ".env")
|
||||
|
||||
# Configure logging to reduce noise during tests
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
|
||||
|
||||
def _get_dts_endpoint() -> str:
|
||||
"""Get the DTS endpoint from environment or use default."""
|
||||
return os.getenv("ENDPOINT", "http://localhost:8080")
|
||||
|
||||
|
||||
def _check_dts_available(endpoint: str | None = None) -> bool:
|
||||
"""Check if DTS emulator is available at the given endpoint."""
|
||||
try:
|
||||
resolved_endpoint: str = _get_dts_endpoint() if endpoint is None else endpoint
|
||||
DurableTaskSchedulerClient(
|
||||
host_address=resolved_endpoint,
|
||||
secure_channel=False,
|
||||
taskhub="test",
|
||||
token_credential=None,
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _check_redis_available() -> bool:
|
||||
"""Check if Redis is available at the default connection string."""
|
||||
try:
|
||||
|
||||
async def test_connection() -> bool:
|
||||
redis_url = os.getenv("REDIS_CONNECTION_STRING", "redis://localhost:6379")
|
||||
try:
|
||||
client = aioredis.from_url(redis_url, socket_timeout=2) # type: ignore[reportUnknownMemberType]
|
||||
await client.ping() # type: ignore[reportUnknownMemberType]
|
||||
await client.aclose() # type: ignore[reportUnknownMemberType]
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
return asyncio.run(test_connection())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def pytest_configure(config: pytest.Config) -> None:
|
||||
"""Register custom markers."""
|
||||
config.addinivalue_line("markers", "integration_test: mark test as integration test")
|
||||
config.addinivalue_line("markers", "requires_dts: mark test as requiring DTS emulator")
|
||||
config.addinivalue_line("markers", "requires_azure_openai: mark test as requiring Azure OpenAI")
|
||||
config.addinivalue_line("markers", "requires_redis: mark test as requiring Redis")
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"sample(path): specify the sample directory name for the test (e.g., @pytest.mark.sample('01_single_agent'))",
|
||||
)
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
|
||||
"""Skip tests based on markers and environment availability."""
|
||||
run_integration = os.getenv("RUN_INTEGRATION_TESTS", "false").lower() == "true"
|
||||
skip_integration = pytest.mark.skip(reason="RUN_INTEGRATION_TESTS not set to 'true'")
|
||||
|
||||
# Check Azure OpenAI environment variables
|
||||
azure_openai_vars = ["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]
|
||||
azure_openai_available = all(os.getenv(var) for var in azure_openai_vars)
|
||||
skip_azure_openai = pytest.mark.skip(
|
||||
reason=f"Missing required environment variables: {', '.join(azure_openai_vars)}"
|
||||
)
|
||||
|
||||
# Check DTS availability
|
||||
dts_available = _check_dts_available()
|
||||
skip_dts = pytest.mark.skip(reason=f"DTS emulator is not available at {_get_dts_endpoint()}")
|
||||
|
||||
# Check Redis availability
|
||||
redis_available = _check_redis_available()
|
||||
skip_redis = pytest.mark.skip(reason="Redis is not available at redis://localhost:6379")
|
||||
|
||||
for item in items:
|
||||
if "integration_test" in item.keywords and not run_integration:
|
||||
item.add_marker(skip_integration)
|
||||
if "requires_azure_openai" in item.keywords and not azure_openai_available:
|
||||
item.add_marker(skip_azure_openai)
|
||||
if "requires_dts" in item.keywords and not dts_available:
|
||||
item.add_marker(skip_dts)
|
||||
if "requires_redis" in item.keywords and not redis_available:
|
||||
item.add_marker(skip_redis)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def dts_endpoint() -> str:
|
||||
"""Get the DTS endpoint from environment or use default."""
|
||||
return _get_dts_endpoint()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def dts_available(dts_endpoint: str) -> bool:
|
||||
"""Check if DTS emulator is available and responding."""
|
||||
if _check_dts_available(dts_endpoint):
|
||||
return True
|
||||
pytest.skip(f"DTS emulator is not available at {dts_endpoint}")
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def check_azure_openai_env() -> None:
|
||||
"""Verify Azure OpenAI environment variables are set."""
|
||||
required_vars = ["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]
|
||||
missing = [var for var in required_vars if not os.getenv(var)]
|
||||
|
||||
if missing:
|
||||
pytest.skip(f"Missing required environment variables: {', '.join(missing)}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def unique_taskhub() -> str:
|
||||
"""Generate a unique task hub name for test isolation."""
|
||||
# Use a shorter UUID to avoid naming issues
|
||||
return f"test-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def worker_process(
|
||||
dts_available: bool,
|
||||
check_azure_openai_env: None,
|
||||
dts_endpoint: str,
|
||||
unique_taskhub: str,
|
||||
request: pytest.FixtureRequest,
|
||||
) -> Generator[dict[str, Any], None, None]:
|
||||
"""
|
||||
Start a worker process for the current test module by running the sample worker.py.
|
||||
|
||||
This fixture:
|
||||
1. Determines which sample to run from @pytest.mark.sample()
|
||||
2. Starts the sample's worker.py as a subprocess
|
||||
3. Waits for the worker to be ready
|
||||
4. Tears down the worker after tests complete
|
||||
|
||||
Usage:
|
||||
@pytest.mark.sample("01_single_agent")
|
||||
class TestSingleAgent:
|
||||
...
|
||||
"""
|
||||
# Get sample path from marker
|
||||
sample_marker = request.node.get_closest_marker("sample") # type: ignore[union-attr]
|
||||
if not sample_marker:
|
||||
pytest.fail("Test class must have @pytest.mark.sample() marker")
|
||||
|
||||
sample_name: str = cast(str, sample_marker.args[0]) # type: ignore[union-attr]
|
||||
sample_path: Path = Path(__file__).parents[4] / "samples" / "getting_started" / "durabletask" / sample_name
|
||||
worker_file: Path = sample_path / "worker.py"
|
||||
|
||||
if not worker_file.exists():
|
||||
pytest.fail(f"Sample worker not found: {worker_file}")
|
||||
|
||||
# Set up environment for worker subprocess
|
||||
env = os.environ.copy()
|
||||
env["ENDPOINT"] = dts_endpoint
|
||||
env["TASKHUB"] = unique_taskhub
|
||||
|
||||
# Start worker subprocess
|
||||
try:
|
||||
# On Windows, use CREATE_NEW_PROCESS_GROUP to allow proper termination
|
||||
# shell=True only on Windows to handle PATH resolution
|
||||
if sys.platform == "win32":
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, str(worker_file)],
|
||||
cwd=str(sample_path),
|
||||
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
|
||||
shell=True,
|
||||
env=env,
|
||||
text=True,
|
||||
)
|
||||
# On Unix, don't use shell=True to avoid shell wrapper issues
|
||||
else:
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, str(worker_file)],
|
||||
cwd=str(sample_path),
|
||||
env=env,
|
||||
text=True,
|
||||
)
|
||||
except Exception as e:
|
||||
pytest.fail(f"Failed to start worker subprocess: {e}")
|
||||
|
||||
# Wait for worker to initialize
|
||||
time.sleep(2)
|
||||
|
||||
# Check if process is still running
|
||||
if process.poll() is not None:
|
||||
stderr_output = process.stderr.read() if process.stderr else ""
|
||||
pytest.fail(f"Worker process exited prematurely. stderr: {stderr_output}")
|
||||
|
||||
# Provide worker info to tests
|
||||
worker_info = {
|
||||
"process": process,
|
||||
"endpoint": dts_endpoint,
|
||||
"taskhub": unique_taskhub,
|
||||
}
|
||||
|
||||
try:
|
||||
yield worker_info
|
||||
finally:
|
||||
# Cleanup: terminate worker subprocess
|
||||
try:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
except Exception as e:
|
||||
logging.warning(f"Error during worker process cleanup: {e}")
|
||||
@@ -0,0 +1,205 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Test utilities for durabletask integration tests."""
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from durabletask.azuremanaged.client import DurableTaskSchedulerClient
|
||||
from durabletask.client import OrchestrationStatus
|
||||
|
||||
from agent_framework_durabletask import DurableAIAgentClient
|
||||
|
||||
|
||||
def create_dts_client(endpoint: str, taskhub: str) -> DurableTaskSchedulerClient:
|
||||
"""
|
||||
Create a DurableTaskSchedulerClient with common configuration.
|
||||
|
||||
Args:
|
||||
endpoint: The DTS endpoint address
|
||||
taskhub: The task hub name
|
||||
|
||||
Returns:
|
||||
A configured DurableTaskSchedulerClient instance
|
||||
"""
|
||||
return DurableTaskSchedulerClient(
|
||||
host_address=endpoint,
|
||||
secure_channel=False,
|
||||
taskhub=taskhub,
|
||||
token_credential=None,
|
||||
)
|
||||
|
||||
|
||||
def create_agent_client(
|
||||
endpoint: str,
|
||||
taskhub: str,
|
||||
max_poll_retries: int = 90,
|
||||
) -> tuple[DurableTaskSchedulerClient, DurableAIAgentClient]:
|
||||
"""
|
||||
Create a DurableAIAgentClient with the underlying DTS client.
|
||||
|
||||
Args:
|
||||
endpoint: The DTS endpoint address
|
||||
taskhub: The task hub name
|
||||
max_poll_retries: Max poll retries for the agent client
|
||||
|
||||
Returns:
|
||||
A tuple of (DurableTaskSchedulerClient, DurableAIAgentClient)
|
||||
"""
|
||||
dts_client = create_dts_client(endpoint, taskhub)
|
||||
agent_client = DurableAIAgentClient(dts_client, max_poll_retries=max_poll_retries)
|
||||
return dts_client, agent_client
|
||||
|
||||
|
||||
class OrchestrationHelper:
|
||||
"""Helper class for orchestration-related test operations."""
|
||||
|
||||
def __init__(self, dts_client: DurableTaskSchedulerClient):
|
||||
"""
|
||||
Initialize the orchestration helper.
|
||||
|
||||
Args:
|
||||
dts_client: The DurableTaskSchedulerClient instance to use
|
||||
"""
|
||||
self.client = dts_client
|
||||
|
||||
def wait_for_orchestration(
|
||||
self,
|
||||
instance_id: str,
|
||||
timeout: float = 60.0,
|
||||
) -> Any:
|
||||
"""
|
||||
Wait for an orchestration to complete.
|
||||
|
||||
Args:
|
||||
instance_id: The orchestration instance ID
|
||||
timeout: Maximum time to wait in seconds
|
||||
|
||||
Returns:
|
||||
The final OrchestrationMetadata
|
||||
|
||||
Raises:
|
||||
TimeoutError: If the orchestration doesn't complete within timeout
|
||||
RuntimeError: If the orchestration fails
|
||||
"""
|
||||
# Use the built-in wait_for_orchestration_completion method
|
||||
metadata = self.client.wait_for_orchestration_completion(
|
||||
instance_id=instance_id,
|
||||
timeout=int(timeout),
|
||||
)
|
||||
|
||||
if metadata is None:
|
||||
raise TimeoutError(f"Orchestration {instance_id} did not complete within {timeout} seconds")
|
||||
|
||||
# Check if failed or terminated
|
||||
if metadata.runtime_status == OrchestrationStatus.FAILED:
|
||||
raise RuntimeError(f"Orchestration {instance_id} failed: {metadata.serialized_custom_status}")
|
||||
if metadata.runtime_status == OrchestrationStatus.TERMINATED:
|
||||
raise RuntimeError(f"Orchestration {instance_id} was terminated")
|
||||
|
||||
return metadata
|
||||
|
||||
def wait_for_orchestration_with_output(
|
||||
self,
|
||||
instance_id: str,
|
||||
timeout: float = 60.0,
|
||||
) -> tuple[Any, Any]:
|
||||
"""
|
||||
Wait for an orchestration to complete and return its output.
|
||||
|
||||
Args:
|
||||
instance_id: The orchestration instance ID
|
||||
timeout: Maximum time to wait in seconds
|
||||
|
||||
Returns:
|
||||
A tuple of (OrchestrationMetadata, output)
|
||||
|
||||
Raises:
|
||||
TimeoutError: If the orchestration doesn't complete within timeout
|
||||
RuntimeError: If the orchestration fails
|
||||
"""
|
||||
metadata = self.wait_for_orchestration(instance_id, timeout)
|
||||
|
||||
# The output should be available in the metadata
|
||||
return metadata, metadata.serialized_output
|
||||
|
||||
def get_orchestration_status(self, instance_id: str) -> Any | None:
|
||||
"""
|
||||
Get the current status of an orchestration.
|
||||
|
||||
Args:
|
||||
instance_id: The orchestration instance ID
|
||||
|
||||
Returns:
|
||||
The OrchestrationMetadata or None if not found
|
||||
"""
|
||||
try:
|
||||
# Try to wait with a short timeout to get current status
|
||||
return self.client.wait_for_orchestration_completion(
|
||||
instance_id=instance_id,
|
||||
timeout=1, # Very short timeout, just checking status
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def raise_event(
|
||||
self,
|
||||
instance_id: str,
|
||||
event_name: str,
|
||||
event_data: Any = None,
|
||||
) -> None:
|
||||
"""
|
||||
Raise an external event to an orchestration.
|
||||
|
||||
Args:
|
||||
instance_id: The orchestration instance ID
|
||||
event_name: The name of the event
|
||||
event_data: The event data payload
|
||||
"""
|
||||
self.client.raise_orchestration_event(instance_id, event_name, data=event_data)
|
||||
|
||||
def wait_for_notification(self, instance_id: str, timeout_seconds: int = 30) -> bool:
|
||||
"""Wait for the orchestration to reach a notification point.
|
||||
|
||||
Polls the orchestration status until it appears to be waiting for approval.
|
||||
|
||||
Args:
|
||||
instance_id: The orchestration instance ID
|
||||
timeout_seconds: Maximum time to wait
|
||||
|
||||
Returns:
|
||||
True if notification detected, False if timeout
|
||||
"""
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout_seconds:
|
||||
try:
|
||||
metadata = self.client.get_orchestration_state(
|
||||
instance_id=instance_id,
|
||||
)
|
||||
|
||||
if metadata:
|
||||
# Check if we're waiting for approval by examining custom status
|
||||
if metadata.serialized_custom_status:
|
||||
try:
|
||||
custom_status = json.loads(metadata.serialized_custom_status)
|
||||
# Handle both string and dict custom status
|
||||
status_str = custom_status if isinstance(custom_status, str) else str(custom_status)
|
||||
if status_str.lower().startswith("requesting human feedback"):
|
||||
return True
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
# If it's not JSON, treat as plain string
|
||||
if metadata.serialized_custom_status.lower().startswith("requesting human feedback"):
|
||||
return True
|
||||
|
||||
# Check for terminal states
|
||||
if metadata.runtime_status.name == "COMPLETED" or metadata.runtime_status.name == "FAILED":
|
||||
return False
|
||||
except Exception:
|
||||
# Silently ignore transient errors during polling (e.g., network issues, service unavailable).
|
||||
# The loop will retry until timeout, allowing the service to recover.
|
||||
pass
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
return False
|
||||
@@ -0,0 +1,89 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Integration tests for single agent functionality.
|
||||
|
||||
Tests basic agent operations including:
|
||||
- Agent registration and retrieval
|
||||
- Single agent interactions
|
||||
- Conversation continuity across multiple messages
|
||||
- Multi-threaded agent usage
|
||||
- Empty thread ID handling
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from dt_testutils import create_agent_client
|
||||
|
||||
# Module-level markers - applied to all tests in this module
|
||||
pytestmark = [
|
||||
pytest.mark.sample("01_single_agent"),
|
||||
pytest.mark.integration_test,
|
||||
pytest.mark.requires_azure_openai,
|
||||
pytest.mark.requires_dts,
|
||||
]
|
||||
|
||||
|
||||
class TestSingleAgent:
|
||||
"""Test suite for single agent functionality."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self, worker_process: dict[str, Any], dts_endpoint: str) -> None:
|
||||
"""Setup test fixtures."""
|
||||
self.endpoint: str = dts_endpoint
|
||||
self.taskhub: str = str(worker_process["taskhub"])
|
||||
|
||||
# Create agent client
|
||||
_, self.agent_client = create_agent_client(self.endpoint, self.taskhub)
|
||||
|
||||
def test_agent_registration(self) -> None:
|
||||
"""Test that the Joker agent is registered and accessible."""
|
||||
agent = self.agent_client.get_agent("Joker")
|
||||
assert agent is not None
|
||||
assert agent.name == "Joker"
|
||||
|
||||
def test_single_interaction(self):
|
||||
"""Test a single interaction with the agent."""
|
||||
agent = self.agent_client.get_agent("Joker")
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
response = agent.run("Tell me a short joke about programming.", thread=thread)
|
||||
|
||||
assert response is not None
|
||||
assert response.text is not None
|
||||
assert len(response.text) > 0
|
||||
|
||||
def test_conversation_continuity(self):
|
||||
"""Test that conversation context is maintained across turns."""
|
||||
agent = self.agent_client.get_agent("Joker")
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
# First turn: Ask for a joke about a specific topic
|
||||
response1 = agent.run("Tell me a joke about cats.", thread=thread)
|
||||
assert response1 is not None
|
||||
assert len(response1.text) > 0
|
||||
|
||||
# Second turn: Ask a follow-up that requires context
|
||||
response2 = agent.run("Can you make it funnier?", thread=thread)
|
||||
assert response2 is not None
|
||||
assert len(response2.text) > 0
|
||||
|
||||
# The agent should understand "it" refers to the previous joke
|
||||
|
||||
def test_multiple_threads(self):
|
||||
"""Test that different threads maintain separate contexts."""
|
||||
agent = self.agent_client.get_agent("Joker")
|
||||
|
||||
# Create two separate threads
|
||||
thread1 = agent.get_new_thread()
|
||||
thread2 = agent.get_new_thread()
|
||||
|
||||
assert thread1.session_id != thread2.session_id
|
||||
|
||||
# Send different messages to each thread
|
||||
response1 = agent.run("Tell me a joke about dogs.", thread=thread1)
|
||||
response2 = agent.run("Tell me a joke about birds.", thread=thread2)
|
||||
|
||||
assert response1 is not None
|
||||
assert response2 is not None
|
||||
assert response1.text != response2.text
|
||||
@@ -0,0 +1,103 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Integration tests for multi-agent functionality.
|
||||
|
||||
Tests operations with multiple specialized agents:
|
||||
- Multiple agent registration
|
||||
- Agent-specific tool usage
|
||||
- Independent thread management per agent
|
||||
- Concurrent agent operations
|
||||
- Agent isolation and tool routing
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from dt_testutils import create_agent_client
|
||||
|
||||
# Agent names from the 02_multi_agent sample
|
||||
WEATHER_AGENT_NAME: str = "WeatherAgent"
|
||||
MATH_AGENT_NAME: str = "MathAgent"
|
||||
|
||||
# Module-level markers - applied to all tests in this module
|
||||
pytestmark = [
|
||||
pytest.mark.sample("02_multi_agent"),
|
||||
pytest.mark.integration_test,
|
||||
pytest.mark.requires_azure_openai,
|
||||
pytest.mark.requires_dts,
|
||||
]
|
||||
|
||||
|
||||
class TestMultiAgent:
|
||||
"""Test suite for multi-agent functionality."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self, worker_process: dict[str, Any], dts_endpoint: str) -> None:
|
||||
"""Setup test fixtures."""
|
||||
self.endpoint: str = dts_endpoint
|
||||
self.taskhub: str = str(worker_process["taskhub"])
|
||||
|
||||
# Create agent client
|
||||
_, self.agent_client = create_agent_client(self.endpoint, self.taskhub)
|
||||
|
||||
def test_multiple_agents_registered(self) -> None:
|
||||
"""Test that both agents are registered and accessible."""
|
||||
weather_agent = self.agent_client.get_agent(WEATHER_AGENT_NAME)
|
||||
math_agent = self.agent_client.get_agent(MATH_AGENT_NAME)
|
||||
|
||||
assert weather_agent is not None
|
||||
assert weather_agent.name == WEATHER_AGENT_NAME
|
||||
assert math_agent is not None
|
||||
assert math_agent.name == MATH_AGENT_NAME
|
||||
|
||||
def test_weather_agent_with_tool(self):
|
||||
"""Test weather agent with weather tool execution."""
|
||||
agent = self.agent_client.get_agent(WEATHER_AGENT_NAME)
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
response = agent.run("What's the weather in Seattle?", thread=thread)
|
||||
|
||||
assert response is not None
|
||||
assert response.text is not None
|
||||
# Should contain weather information from the tool
|
||||
assert len(response.text) > 0
|
||||
|
||||
# Verify that the get_weather tool was actually invoked
|
||||
tool_calls = [
|
||||
content for msg in response.messages for content in msg.contents if content.type == "function_call"
|
||||
]
|
||||
assert len(tool_calls) > 0, "Expected at least one tool call"
|
||||
assert any(call.name == "get_weather" for call in tool_calls), "Expected get_weather tool to be called"
|
||||
|
||||
def test_math_agent_with_tool(self):
|
||||
"""Test math agent with calculation tool execution."""
|
||||
agent = self.agent_client.get_agent(MATH_AGENT_NAME)
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
response = agent.run("Calculate a 20% tip on a $50 bill.", thread=thread)
|
||||
|
||||
assert response is not None
|
||||
assert response.text is not None
|
||||
# Should contain calculation results from the tool
|
||||
assert len(response.text) > 0
|
||||
|
||||
# Verify that the calculate_tip tool was actually invoked
|
||||
tool_calls = [
|
||||
content for msg in response.messages for content in msg.contents if content.type == "function_call"
|
||||
]
|
||||
assert len(tool_calls) > 0, "Expected at least one tool call"
|
||||
assert any(call.name == "calculate_tip" for call in tool_calls), "Expected calculate_tip tool to be called"
|
||||
|
||||
def test_multiple_calls_to_same_agent(self):
|
||||
"""Test multiple sequential calls to the same agent."""
|
||||
agent = self.agent_client.get_agent(WEATHER_AGENT_NAME)
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
# Multiple weather queries
|
||||
response1 = agent.run("What's the weather in Chicago?", thread=thread)
|
||||
response2 = agent.run("And what about Los Angeles?", thread=thread)
|
||||
|
||||
assert response1 is not None
|
||||
assert response2 is not None
|
||||
assert len(response1.text) > 0
|
||||
assert len(response2.text) > 0
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""
|
||||
Integration Tests for Reliable Streaming Sample
|
||||
|
||||
Tests the reliable streaming sample using Redis Streams for persistent message delivery.
|
||||
|
||||
The worker process is automatically started by the test fixture.
|
||||
|
||||
Prerequisites:
|
||||
- Azure OpenAI credentials configured (see packages/durabletask/tests/integration_tests/.env.example)
|
||||
- DTS emulator running (docker run -d -p 8080:8080 mcr.microsoft.com/durabletask/emulator:latest)
|
||||
- Redis running (docker run -d --name redis -p 6379:6379 redis:latest)
|
||||
|
||||
Usage:
|
||||
uv run pytest packages/durabletask/tests/integration_tests/test_03_single_agent_streaming.py -v
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import redis.asyncio as aioredis
|
||||
from dt_testutils import OrchestrationHelper, create_agent_client
|
||||
|
||||
# Add sample directory to path to import RedisStreamResponseHandler
|
||||
SAMPLE_DIR = Path(__file__).parents[4] / "samples" / "getting_started" / "durabletask" / "03_single_agent_streaming"
|
||||
sys.path.insert(0, str(SAMPLE_DIR))
|
||||
|
||||
from redis_stream_response_handler import RedisStreamResponseHandler # type: ignore[reportMissingImports] # noqa: E402
|
||||
|
||||
# Module-level markers - applied to all tests in this file
|
||||
pytestmark = [
|
||||
pytest.mark.sample("03_single_agent_streaming"),
|
||||
pytest.mark.integration_test,
|
||||
pytest.mark.requires_azure_openai,
|
||||
pytest.mark.requires_dts,
|
||||
pytest.mark.requires_redis,
|
||||
]
|
||||
|
||||
|
||||
class TestSampleReliableStreaming:
|
||||
"""Tests for 03_single_agent_streaming sample."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self, worker_process: dict[str, Any], dts_endpoint: str) -> None:
|
||||
"""Setup test fixtures."""
|
||||
self.endpoint: str = dts_endpoint
|
||||
self.taskhub: str = str(worker_process["taskhub"])
|
||||
|
||||
# Create agent client
|
||||
dts_client, self.agent_client = create_agent_client(self.endpoint, self.taskhub)
|
||||
self.helper = OrchestrationHelper(dts_client)
|
||||
|
||||
# Redis configuration
|
||||
self.redis_connection_string = os.environ.get("REDIS_CONNECTION_STRING", "redis://localhost:6379")
|
||||
self.redis_stream_ttl_minutes = int(os.environ.get("REDIS_STREAM_TTL_MINUTES", "10"))
|
||||
|
||||
async def _get_stream_handler(self) -> RedisStreamResponseHandler: # type: ignore[reportMissingTypeStubs]
|
||||
"""Create a new Redis stream handler for each request."""
|
||||
redis_client = aioredis.from_url( # type: ignore[reportUnknownMemberType]
|
||||
self.redis_connection_string,
|
||||
encoding="utf-8",
|
||||
decode_responses=False,
|
||||
)
|
||||
return RedisStreamResponseHandler( # type: ignore[reportUnknownMemberType]
|
||||
redis_client=redis_client,
|
||||
stream_ttl=timedelta(minutes=self.redis_stream_ttl_minutes),
|
||||
)
|
||||
|
||||
async def _stream_from_redis(
|
||||
self,
|
||||
thread_id: str,
|
||||
cursor: str | None = None,
|
||||
timeout: float = 30.0,
|
||||
) -> tuple[str, bool, str]:
|
||||
"""
|
||||
Stream responses from Redis using the sample's RedisStreamResponseHandler.
|
||||
|
||||
Args:
|
||||
thread_id: The conversation/thread ID to stream from
|
||||
cursor: Optional cursor to resume from
|
||||
timeout: Maximum time to wait for stream completion
|
||||
|
||||
Returns:
|
||||
Tuple of (accumulated text, completion status, last entry_id)
|
||||
"""
|
||||
accumulated_text = ""
|
||||
is_complete = False
|
||||
last_entry_id = cursor if cursor else "0-0"
|
||||
start_time = time.time()
|
||||
|
||||
async with await self._get_stream_handler() as stream_handler: # type: ignore[reportUnknownMemberType]
|
||||
try:
|
||||
async for chunk in stream_handler.read_stream(thread_id, cursor): # type: ignore[reportUnknownMemberType]
|
||||
if time.time() - start_time > timeout:
|
||||
break
|
||||
|
||||
last_entry_id = chunk.entry_id # type: ignore[reportUnknownMemberType]
|
||||
|
||||
if chunk.error: # type: ignore[reportUnknownMemberType]
|
||||
# Stream not found or timeout - this is expected if agent hasn't written yet
|
||||
# Don't raise an error, just return what we have
|
||||
break
|
||||
|
||||
if chunk.is_done: # type: ignore[reportUnknownMemberType]
|
||||
is_complete = True
|
||||
break
|
||||
|
||||
if chunk.text: # type: ignore[reportUnknownMemberType]
|
||||
accumulated_text += chunk.text # type: ignore[reportUnknownMemberType]
|
||||
|
||||
except Exception as ex:
|
||||
# For test purposes, we catch exceptions and return what we have
|
||||
if "timed out" not in str(ex).lower():
|
||||
raise
|
||||
|
||||
return accumulated_text, is_complete, last_entry_id # type: ignore[reportReturnType]
|
||||
|
||||
def test_agent_run_and_stream(self) -> None:
|
||||
"""Test agent execution with Redis streaming."""
|
||||
# Get the TravelPlanner agent
|
||||
travel_planner = self.agent_client.get_agent("TravelPlanner")
|
||||
assert travel_planner is not None
|
||||
assert travel_planner.name == "TravelPlanner"
|
||||
|
||||
# Create a new thread
|
||||
thread = travel_planner.get_new_thread()
|
||||
assert thread.session_id is not None
|
||||
assert thread.session_id.key is not None
|
||||
thread_id = str(thread.session_id.key)
|
||||
|
||||
# Start agent run with wait_for_response=False for non-blocking execution
|
||||
travel_planner.run(
|
||||
"Plan a 1-day trip to Seattle in 1 sentence", thread=thread, options={"wait_for_response": False}
|
||||
)
|
||||
|
||||
# Poll Redis stream with retries to handle race conditions
|
||||
# The agent may take a few seconds to process and start writing to Redis
|
||||
# We use cursor-based resumption to continue reading from where we left off
|
||||
max_retries = 20
|
||||
retry_count = 0
|
||||
accumulated_text = ""
|
||||
is_complete = False
|
||||
cursor: str | None = None
|
||||
|
||||
while retry_count < max_retries and not is_complete:
|
||||
text, is_complete, last_cursor = asyncio.run(
|
||||
self._stream_from_redis(thread_id, cursor=cursor, timeout=10.0)
|
||||
)
|
||||
accumulated_text += text
|
||||
cursor = last_cursor # Resume from last position on next read
|
||||
|
||||
if is_complete:
|
||||
# Stream completed successfully
|
||||
break
|
||||
|
||||
if len(accumulated_text) > 0:
|
||||
# Got content but not completion marker yet - keep reading without delay
|
||||
# The agent may still be streaming or about to write completion marker
|
||||
continue
|
||||
|
||||
# No content yet - wait before retrying
|
||||
time.sleep(2)
|
||||
retry_count += 1
|
||||
|
||||
# Verify we got content
|
||||
assert len(accumulated_text) > 0, (
|
||||
f"Expected text content but got empty string for thread_id: {thread_id} after {retry_count} retries"
|
||||
)
|
||||
assert "seattle" in accumulated_text.lower(), f"Expected 'seattle' in response but got: {accumulated_text}"
|
||||
assert is_complete, "Expected stream to be complete"
|
||||
|
||||
def test_stream_with_cursor_resumption(self) -> None:
|
||||
"""Test streaming with cursor-based resumption."""
|
||||
# Get the TravelPlanner agent
|
||||
travel_planner = self.agent_client.get_agent("TravelPlanner")
|
||||
thread = travel_planner.get_new_thread()
|
||||
assert thread.session_id is not None
|
||||
assert thread.session_id.key is not None
|
||||
thread_id = str(thread.session_id.key)
|
||||
|
||||
# Start agent run
|
||||
travel_planner.run("What's the weather like?", thread=thread, options={"wait_for_response": False})
|
||||
|
||||
# Wait for agent to start writing
|
||||
time.sleep(3)
|
||||
|
||||
# Read partial stream to get a cursor
|
||||
async def get_partial_stream() -> tuple[str, str]:
|
||||
async with await self._get_stream_handler() as stream_handler: # type: ignore[reportUnknownMemberType]
|
||||
accumulated_text = ""
|
||||
last_entry_id = "0-0"
|
||||
chunk_count = 0
|
||||
|
||||
# Read just first 2 chunks
|
||||
async for chunk in stream_handler.read_stream(thread_id): # type: ignore[reportUnknownMemberType]
|
||||
last_entry_id = chunk.entry_id # type: ignore[reportUnknownMemberType]
|
||||
if chunk.text: # type: ignore[reportUnknownMemberType]
|
||||
accumulated_text += chunk.text # type: ignore[reportUnknownMemberType]
|
||||
chunk_count += 1
|
||||
if chunk_count >= 2:
|
||||
break
|
||||
|
||||
return accumulated_text, last_entry_id # type: ignore[reportReturnType]
|
||||
|
||||
partial_text, cursor = asyncio.run(get_partial_stream())
|
||||
|
||||
# Resume from cursor
|
||||
remaining_text, _, _ = asyncio.run(self._stream_from_redis(thread_id, cursor=cursor))
|
||||
|
||||
# Verify we got some initial content
|
||||
assert len(partial_text) > 0
|
||||
|
||||
# Combined text should be coherent
|
||||
full_text = partial_text + remaining_text
|
||||
assert len(full_text) > 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Integration tests for single agent orchestration with chaining.
|
||||
|
||||
Tests orchestration patterns with sequential agent calls:
|
||||
- Orchestration registration and execution
|
||||
- Sequential agent calls on same thread
|
||||
- Conversation continuity in orchestrations
|
||||
- Thread context preservation
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from dt_testutils import OrchestrationHelper, create_agent_client
|
||||
from durabletask.client import OrchestrationStatus
|
||||
|
||||
# Agent name from the 04_single_agent_orchestration_chaining sample
|
||||
WRITER_AGENT_NAME: str = "WriterAgent"
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
|
||||
# Module-level markers - applied to all tests in this module
|
||||
pytestmark = [
|
||||
pytest.mark.sample("04_single_agent_orchestration_chaining"),
|
||||
pytest.mark.integration_test,
|
||||
pytest.mark.requires_azure_openai,
|
||||
pytest.mark.requires_dts,
|
||||
]
|
||||
|
||||
|
||||
class TestSingleAgentOrchestrationChaining:
|
||||
"""Test suite for single agent orchestration with chaining."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self, worker_process: dict[str, Any], dts_endpoint: str) -> None:
|
||||
"""Setup test fixtures."""
|
||||
self.endpoint: str = dts_endpoint
|
||||
self.taskhub: str = str(worker_process["taskhub"])
|
||||
|
||||
# Create agent client and DTS client
|
||||
self.dts_client, self.agent_client = create_agent_client(self.endpoint, self.taskhub)
|
||||
|
||||
# Create orchestration helper
|
||||
self.orch_helper = OrchestrationHelper(self.dts_client)
|
||||
|
||||
def test_agent_registered(self):
|
||||
"""Test that the Writer agent is registered."""
|
||||
agent = self.agent_client.get_agent(WRITER_AGENT_NAME)
|
||||
assert agent is not None
|
||||
assert agent.name == WRITER_AGENT_NAME
|
||||
|
||||
def test_chaining_context_preserved(self):
|
||||
"""Test that context is preserved across agent runs in orchestration."""
|
||||
# Start the orchestration
|
||||
instance_id = self.dts_client.schedule_new_orchestration(
|
||||
orchestrator="single_agent_chaining_orchestration",
|
||||
input="",
|
||||
)
|
||||
|
||||
# Wait for completion with output
|
||||
metadata, output = self.orch_helper.wait_for_orchestration_with_output(
|
||||
instance_id=instance_id,
|
||||
timeout=120.0,
|
||||
)
|
||||
|
||||
assert metadata is not None
|
||||
assert output is not None
|
||||
|
||||
# The final output should be a refined sentence
|
||||
final_text = json.loads(output)
|
||||
|
||||
# Should be a meaningful sentence (not empty or error message)
|
||||
assert len(final_text) > 10
|
||||
assert not final_text.startswith("Error")
|
||||
|
||||
def test_multiple_orchestration_instances(self):
|
||||
"""Test that multiple orchestration instances can run independently."""
|
||||
# Start two orchestrations
|
||||
instance_id_1 = self.dts_client.schedule_new_orchestration(
|
||||
orchestrator="single_agent_chaining_orchestration",
|
||||
input="",
|
||||
)
|
||||
instance_id_2 = self.dts_client.schedule_new_orchestration(
|
||||
orchestrator="single_agent_chaining_orchestration",
|
||||
input="",
|
||||
)
|
||||
|
||||
assert instance_id_1 != instance_id_2
|
||||
|
||||
# Both should complete
|
||||
metadata_1 = self.orch_helper.wait_for_orchestration(
|
||||
instance_id=instance_id_1,
|
||||
timeout=120.0,
|
||||
)
|
||||
metadata_2 = self.orch_helper.wait_for_orchestration(
|
||||
instance_id=instance_id_2,
|
||||
timeout=120.0,
|
||||
)
|
||||
|
||||
assert metadata_1.runtime_status == OrchestrationStatus.COMPLETED
|
||||
assert metadata_2.runtime_status == OrchestrationStatus.COMPLETED
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Integration tests for multi-agent orchestration with concurrency.
|
||||
|
||||
Tests concurrent execution patterns:
|
||||
- Parallel agent execution
|
||||
- Concurrent orchestration tasks
|
||||
- Independent thread management in parallel
|
||||
- Result aggregation from concurrent calls
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from dt_testutils import OrchestrationHelper, create_agent_client
|
||||
from durabletask.client import OrchestrationStatus
|
||||
|
||||
# Agent names from the 05_multi_agent_orchestration_concurrency sample
|
||||
PHYSICIST_AGENT_NAME: str = "PhysicistAgent"
|
||||
CHEMIST_AGENT_NAME: str = "ChemistAgent"
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
|
||||
# Module-level markers
|
||||
pytestmark = [
|
||||
pytest.mark.sample("05_multi_agent_orchestration_concurrency"),
|
||||
pytest.mark.integration_test,
|
||||
pytest.mark.requires_dts,
|
||||
]
|
||||
|
||||
|
||||
class TestMultiAgentOrchestrationConcurrency:
|
||||
"""Test suite for multi-agent orchestration with concurrency."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self, worker_process: dict[str, Any], dts_endpoint: str) -> None:
|
||||
"""Setup test fixtures."""
|
||||
self.endpoint = dts_endpoint
|
||||
self.taskhub = worker_process["taskhub"]
|
||||
|
||||
# Create agent client and DTS client
|
||||
self.dts_client, self.agent_client = create_agent_client(self.endpoint, self.taskhub)
|
||||
|
||||
# Create orchestration helper
|
||||
self.orch_helper = OrchestrationHelper(self.dts_client)
|
||||
|
||||
def test_agents_registered(self):
|
||||
"""Test that both agents are registered."""
|
||||
physicist = self.agent_client.get_agent(PHYSICIST_AGENT_NAME)
|
||||
chemist = self.agent_client.get_agent(CHEMIST_AGENT_NAME)
|
||||
|
||||
assert physicist is not None
|
||||
assert physicist.name == PHYSICIST_AGENT_NAME
|
||||
assert chemist is not None
|
||||
assert chemist.name == CHEMIST_AGENT_NAME
|
||||
|
||||
def test_different_prompts(self):
|
||||
"""Test concurrent orchestration with different prompts."""
|
||||
prompts = [
|
||||
"What is temperature?",
|
||||
"Explain molecules.",
|
||||
]
|
||||
|
||||
for prompt in prompts:
|
||||
instance_id = self.dts_client.schedule_new_orchestration(
|
||||
orchestrator="multi_agent_concurrent_orchestration",
|
||||
input=prompt,
|
||||
)
|
||||
|
||||
metadata, output = self.orch_helper.wait_for_orchestration_with_output(
|
||||
instance_id=instance_id,
|
||||
timeout=120.0,
|
||||
)
|
||||
|
||||
assert metadata.runtime_status == OrchestrationStatus.COMPLETED
|
||||
result = json.loads(output)
|
||||
assert "physicist" in result
|
||||
assert "chemist" in result
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Integration tests for multi-agent orchestration with conditionals.
|
||||
|
||||
Tests conditional orchestration patterns:
|
||||
- Conditional branching in orchestrations
|
||||
- Agent-based decision making
|
||||
- Activity function execution
|
||||
- Structured output handling
|
||||
- Conditional routing based on agent responses
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from dt_testutils import OrchestrationHelper, create_agent_client
|
||||
from durabletask.client import OrchestrationStatus
|
||||
|
||||
# Agent names from the 06_multi_agent_orchestration_conditionals sample
|
||||
SPAM_AGENT_NAME: str = "SpamDetectionAgent"
|
||||
EMAIL_AGENT_NAME: str = "EmailAssistantAgent"
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
|
||||
# Module-level markers
|
||||
pytestmark = [
|
||||
pytest.mark.sample("06_multi_agent_orchestration_conditionals"),
|
||||
pytest.mark.integration_test,
|
||||
pytest.mark.requires_dts,
|
||||
]
|
||||
|
||||
|
||||
class TestMultiAgentOrchestrationConditionals:
|
||||
"""Test suite for multi-agent orchestration with conditionals."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self, worker_process: dict[str, Any], dts_endpoint: str) -> None:
|
||||
"""Setup test fixtures."""
|
||||
self.endpoint: str = dts_endpoint
|
||||
self.taskhub: str = str(worker_process["taskhub"])
|
||||
|
||||
# Create agent client and DTS client
|
||||
self.dts_client, self.agent_client = create_agent_client(self.endpoint, self.taskhub)
|
||||
|
||||
# Create orchestration helper
|
||||
self.orch_helper = OrchestrationHelper(self.dts_client)
|
||||
|
||||
def test_agents_registered(self):
|
||||
"""Test that both agents are registered."""
|
||||
spam_agent = self.agent_client.get_agent(SPAM_AGENT_NAME)
|
||||
email_agent = self.agent_client.get_agent(EMAIL_AGENT_NAME)
|
||||
|
||||
assert spam_agent is not None
|
||||
assert spam_agent.name == SPAM_AGENT_NAME
|
||||
assert email_agent is not None
|
||||
assert email_agent.name == EMAIL_AGENT_NAME
|
||||
|
||||
def test_conditional_branching(self):
|
||||
"""Test that conditional branching works correctly."""
|
||||
# Test with obvious spam
|
||||
spam_payload = {
|
||||
"email_id": "spam-001",
|
||||
"email_content": "Buy cheap medications online! No prescription needed! Limited time offer!",
|
||||
}
|
||||
|
||||
spam_instance_id = self.dts_client.schedule_new_orchestration(
|
||||
orchestrator="spam_detection_orchestration",
|
||||
input=spam_payload,
|
||||
)
|
||||
|
||||
# Test with legitimate email
|
||||
legit_payload = {
|
||||
"email_id": "legit-001",
|
||||
"email_content": "Hi team, please review the attached document before our meeting tomorrow.",
|
||||
}
|
||||
|
||||
legit_instance_id = self.dts_client.schedule_new_orchestration(
|
||||
orchestrator="spam_detection_orchestration",
|
||||
input=legit_payload,
|
||||
)
|
||||
|
||||
# Both should complete successfully (different branches)
|
||||
spam_metadata = self.orch_helper.wait_for_orchestration(
|
||||
instance_id=spam_instance_id,
|
||||
timeout=120.0,
|
||||
)
|
||||
legit_metadata = self.orch_helper.wait_for_orchestration(
|
||||
instance_id=legit_instance_id,
|
||||
timeout=120.0,
|
||||
)
|
||||
|
||||
assert spam_metadata.runtime_status == OrchestrationStatus.COMPLETED
|
||||
assert legit_metadata.runtime_status == OrchestrationStatus.COMPLETED
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Integration tests for single agent orchestration with human-in-the-loop.
|
||||
|
||||
Tests human-in-the-loop (HITL) patterns:
|
||||
- External event waiting and handling
|
||||
- Timeout handling in orchestrations
|
||||
- Iterative refinement with human feedback
|
||||
- Activity function integration
|
||||
- Approval workflow patterns
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from dt_testutils import OrchestrationHelper, create_agent_client
|
||||
from durabletask.client import OrchestrationStatus
|
||||
|
||||
# Constants from the 07_single_agent_orchestration_hitl sample
|
||||
WRITER_AGENT_NAME: str = "WriterAgent"
|
||||
HUMAN_APPROVAL_EVENT: str = "HumanApproval"
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
|
||||
# Module-level markers
|
||||
pytestmark = [
|
||||
pytest.mark.sample("07_single_agent_orchestration_hitl"),
|
||||
pytest.mark.integration_test,
|
||||
pytest.mark.requires_dts,
|
||||
]
|
||||
|
||||
|
||||
class TestSingleAgentOrchestrationHITL:
|
||||
"""Test suite for single agent orchestration with human-in-the-loop."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self, worker_process: dict[str, Any], dts_endpoint: str) -> None:
|
||||
"""Setup test fixtures."""
|
||||
self.endpoint: str = str(worker_process["endpoint"])
|
||||
self.taskhub: str = str(worker_process["taskhub"])
|
||||
|
||||
logging.info(f"Using taskhub: {self.taskhub} at endpoint: {self.endpoint}")
|
||||
|
||||
# Create agent client and DTS client
|
||||
self.dts_client, self.agent_client = create_agent_client(self.endpoint, self.taskhub)
|
||||
|
||||
# Create orchestration helper
|
||||
self.orch_helper = OrchestrationHelper(self.dts_client)
|
||||
|
||||
def test_agent_registered(self):
|
||||
"""Test that the Writer agent is registered."""
|
||||
agent = self.agent_client.get_agent(WRITER_AGENT_NAME)
|
||||
assert agent is not None
|
||||
assert agent.name == WRITER_AGENT_NAME
|
||||
|
||||
def test_hitl_orchestration_with_approval(self):
|
||||
"""Test HITL orchestration with immediate approval."""
|
||||
payload = {
|
||||
"topic": "The benefits of continuous learning",
|
||||
"max_review_attempts": 3,
|
||||
"approval_timeout_seconds": 60,
|
||||
}
|
||||
|
||||
# Start the orchestration
|
||||
instance_id = self.dts_client.schedule_new_orchestration(
|
||||
orchestrator="content_generation_hitl_orchestration",
|
||||
input=payload,
|
||||
)
|
||||
|
||||
assert instance_id is not None
|
||||
|
||||
# Wait for orchestration to reach notification point
|
||||
notification_received = self.orch_helper.wait_for_notification(instance_id, timeout_seconds=90)
|
||||
assert notification_received, "Failed to receive notification from orchestration"
|
||||
|
||||
# Send approval event
|
||||
approval_data = {"approved": True, "feedback": ""}
|
||||
self.orch_helper.raise_event(
|
||||
instance_id=instance_id,
|
||||
event_name=HUMAN_APPROVAL_EVENT,
|
||||
event_data=approval_data,
|
||||
)
|
||||
|
||||
# Wait for completion
|
||||
metadata = self.orch_helper.wait_for_orchestration(
|
||||
instance_id=instance_id,
|
||||
timeout=90.0,
|
||||
)
|
||||
|
||||
assert metadata is not None
|
||||
assert metadata.runtime_status == OrchestrationStatus.COMPLETED
|
||||
|
||||
def test_hitl_orchestration_with_rejection_and_feedback(self):
|
||||
"""Test HITL orchestration with rejection and iterative refinement."""
|
||||
payload = {
|
||||
"topic": "Artificial Intelligence in healthcare",
|
||||
"max_review_attempts": 3,
|
||||
"approval_timeout_seconds": 60,
|
||||
}
|
||||
|
||||
# Start the orchestration
|
||||
instance_id = self.dts_client.schedule_new_orchestration(
|
||||
orchestrator="content_generation_hitl_orchestration",
|
||||
input=payload,
|
||||
)
|
||||
|
||||
# Wait for orchestration to reach notification point
|
||||
notification_received = self.orch_helper.wait_for_notification(instance_id, timeout_seconds=90)
|
||||
assert notification_received, "Failed to receive notification from orchestration"
|
||||
|
||||
# First rejection with feedback
|
||||
rejection_data = {
|
||||
"approved": False,
|
||||
"feedback": "Please make it more concise and add specific examples.",
|
||||
}
|
||||
self.orch_helper.raise_event(
|
||||
instance_id=instance_id,
|
||||
event_name=HUMAN_APPROVAL_EVENT,
|
||||
event_data=rejection_data,
|
||||
)
|
||||
|
||||
# Wait for orchestration to refine and reach notification point again
|
||||
notification_received = self.orch_helper.wait_for_notification(instance_id, timeout_seconds=90)
|
||||
assert notification_received, "Failed to receive notification after refinement"
|
||||
|
||||
# Second approval
|
||||
approval_data = {"approved": True, "feedback": ""}
|
||||
self.orch_helper.raise_event(
|
||||
instance_id=instance_id,
|
||||
event_name=HUMAN_APPROVAL_EVENT,
|
||||
event_data=approval_data,
|
||||
)
|
||||
|
||||
# Wait for completion
|
||||
metadata = self.orch_helper.wait_for_orchestration(
|
||||
instance_id=instance_id,
|
||||
timeout=90.0,
|
||||
)
|
||||
|
||||
assert metadata is not None
|
||||
assert metadata.runtime_status == OrchestrationStatus.COMPLETED
|
||||
|
||||
def test_hitl_orchestration_timeout(self):
|
||||
"""Test HITL orchestration timeout behavior."""
|
||||
payload = {
|
||||
"topic": "Cloud computing fundamentals",
|
||||
"max_review_attempts": 1,
|
||||
"approval_timeout_seconds": 0.1, # Short timeout for testing
|
||||
}
|
||||
|
||||
# Start the orchestration
|
||||
instance_id = self.dts_client.schedule_new_orchestration(
|
||||
orchestrator="content_generation_hitl_orchestration",
|
||||
input=payload,
|
||||
)
|
||||
|
||||
# Don't send any approval - let it timeout
|
||||
# The orchestration should fail due to timeout
|
||||
try:
|
||||
metadata = self.orch_helper.wait_for_orchestration(
|
||||
instance_id=instance_id,
|
||||
timeout=90.0,
|
||||
)
|
||||
# If it completes, it should be failed status due to timeout
|
||||
assert metadata.runtime_status == OrchestrationStatus.FAILED
|
||||
except (RuntimeError, TimeoutError):
|
||||
# Expected - orchestration should timeout and fail
|
||||
pass
|
||||
@@ -0,0 +1,300 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for AgentSessionId and DurableAgentThread."""
|
||||
|
||||
import pytest
|
||||
from agent_framework import AgentThread
|
||||
|
||||
from agent_framework_durabletask._models import AgentSessionId, DurableAgentThread
|
||||
|
||||
|
||||
class TestAgentSessionId:
|
||||
"""Test suite for AgentSessionId."""
|
||||
|
||||
def test_init_creates_session_id(self) -> None:
|
||||
"""Test that AgentSessionId initializes correctly."""
|
||||
session_id = AgentSessionId(name="AgentEntity", key="test-key-123")
|
||||
|
||||
assert session_id.name == "AgentEntity"
|
||||
assert session_id.key == "test-key-123"
|
||||
|
||||
def test_with_random_key_generates_guid(self) -> None:
|
||||
"""Test that with_random_key generates a GUID."""
|
||||
session_id = AgentSessionId.with_random_key(name="AgentEntity")
|
||||
|
||||
assert session_id.name == "AgentEntity"
|
||||
assert len(session_id.key) == 32 # UUID hex is 32 chars
|
||||
# Verify it's a valid hex string
|
||||
int(session_id.key, 16)
|
||||
|
||||
def test_with_random_key_unique_keys(self) -> None:
|
||||
"""Test that with_random_key generates unique keys."""
|
||||
session_id1 = AgentSessionId.with_random_key(name="AgentEntity")
|
||||
session_id2 = AgentSessionId.with_random_key(name="AgentEntity")
|
||||
|
||||
assert session_id1.key != session_id2.key
|
||||
|
||||
def test_str_representation(self) -> None:
|
||||
"""Test string representation."""
|
||||
session_id = AgentSessionId(name="AgentEntity", key="test-key-123")
|
||||
str_repr = str(session_id)
|
||||
|
||||
assert str_repr == "@AgentEntity@test-key-123"
|
||||
|
||||
def test_repr_representation(self) -> None:
|
||||
"""Test repr representation."""
|
||||
session_id = AgentSessionId(name="AgentEntity", key="test-key")
|
||||
repr_str = repr(session_id)
|
||||
|
||||
assert "AgentSessionId" in repr_str
|
||||
assert "AgentEntity" in repr_str
|
||||
assert "test-key" in repr_str
|
||||
|
||||
def test_parse_valid_session_id(self) -> None:
|
||||
"""Test parsing valid session ID string."""
|
||||
session_id = AgentSessionId.parse("@AgentEntity@test-key-123")
|
||||
|
||||
assert session_id.name == "AgentEntity"
|
||||
assert session_id.key == "test-key-123"
|
||||
|
||||
def test_parse_invalid_format_no_prefix(self) -> None:
|
||||
"""Test parsing invalid format without @ prefix."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
AgentSessionId.parse("AgentEntity@test-key")
|
||||
|
||||
assert "Invalid agent session ID format" in str(exc_info.value)
|
||||
|
||||
def test_parse_invalid_format_single_part(self) -> None:
|
||||
"""Test parsing invalid format with single part."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
AgentSessionId.parse("@AgentEntity")
|
||||
|
||||
assert "Invalid agent session ID format" in str(exc_info.value)
|
||||
|
||||
def test_parse_with_multiple_at_signs_in_key(self) -> None:
|
||||
"""Test parsing with @ signs in the key."""
|
||||
session_id = AgentSessionId.parse("@AgentEntity@key-with@symbols")
|
||||
|
||||
assert session_id.name == "AgentEntity"
|
||||
assert session_id.key == "key-with@symbols"
|
||||
|
||||
def test_parse_round_trip(self) -> None:
|
||||
"""Test round-trip parse and string conversion."""
|
||||
original = AgentSessionId(name="AgentEntity", key="test-key")
|
||||
str_repr = str(original)
|
||||
parsed = AgentSessionId.parse(str_repr)
|
||||
|
||||
assert parsed.name == original.name
|
||||
assert parsed.key == original.key
|
||||
|
||||
def test_to_entity_name_adds_prefix(self) -> None:
|
||||
"""Test that to_entity_name adds the dafx- prefix."""
|
||||
entity_name = AgentSessionId.to_entity_name("TestAgent")
|
||||
assert entity_name == "dafx-TestAgent"
|
||||
|
||||
def test_parse_with_agent_name_override(self) -> None:
|
||||
"""Test parsing @name@key format with agent_name parameter overrides the name."""
|
||||
session_id = AgentSessionId.parse("@OriginalAgent@test-key-123", agent_name="OverriddenAgent")
|
||||
|
||||
assert session_id.name == "OverriddenAgent"
|
||||
assert session_id.key == "test-key-123"
|
||||
|
||||
def test_parse_without_agent_name_uses_parsed_name(self) -> None:
|
||||
"""Test parsing @name@key format without agent_name uses name from string."""
|
||||
session_id = AgentSessionId.parse("@ParsedAgent@test-key-123")
|
||||
|
||||
assert session_id.name == "ParsedAgent"
|
||||
assert session_id.key == "test-key-123"
|
||||
|
||||
def test_parse_plain_string_with_agent_name(self) -> None:
|
||||
"""Test parsing plain string with agent_name uses entire string as key."""
|
||||
session_id = AgentSessionId.parse("simple-thread-123", agent_name="TestAgent")
|
||||
|
||||
assert session_id.name == "TestAgent"
|
||||
assert session_id.key == "simple-thread-123"
|
||||
|
||||
def test_parse_plain_string_without_agent_name_raises(self) -> None:
|
||||
"""Test parsing plain string without agent_name raises ValueError."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
AgentSessionId.parse("simple-thread-123")
|
||||
|
||||
assert "Invalid agent session ID format" in str(exc_info.value)
|
||||
|
||||
|
||||
class TestDurableAgentThread:
|
||||
"""Test suite for DurableAgentThread."""
|
||||
|
||||
def test_init_with_session_id(self) -> None:
|
||||
"""Test DurableAgentThread initialization with session ID."""
|
||||
session_id = AgentSessionId(name="TestAgent", key="test-key")
|
||||
thread = DurableAgentThread(session_id=session_id)
|
||||
|
||||
assert thread.session_id is not None
|
||||
assert thread.session_id == session_id
|
||||
|
||||
def test_init_without_session_id(self) -> None:
|
||||
"""Test DurableAgentThread initialization without session ID."""
|
||||
thread = DurableAgentThread()
|
||||
|
||||
assert thread.session_id is None
|
||||
|
||||
def test_session_id_setter(self) -> None:
|
||||
"""Test setting a session ID to an existing thread."""
|
||||
thread = DurableAgentThread()
|
||||
assert thread.session_id is None
|
||||
|
||||
session_id = AgentSessionId(name="TestAgent", key="test-key")
|
||||
thread.session_id = session_id
|
||||
|
||||
assert thread.session_id is not None
|
||||
assert thread.session_id == session_id
|
||||
assert thread.session_id.name == "TestAgent"
|
||||
|
||||
def test_from_session_id(self) -> None:
|
||||
"""Test creating DurableAgentThread from session ID."""
|
||||
session_id = AgentSessionId(name="TestAgent", key="test-key")
|
||||
thread = DurableAgentThread.from_session_id(session_id)
|
||||
|
||||
assert isinstance(thread, DurableAgentThread)
|
||||
assert thread.session_id is not None
|
||||
assert thread.session_id == session_id
|
||||
assert thread.session_id.name == "TestAgent"
|
||||
assert thread.session_id.key == "test-key"
|
||||
|
||||
def test_from_session_id_with_service_thread_id(self) -> None:
|
||||
"""Test creating DurableAgentThread with service thread ID."""
|
||||
session_id = AgentSessionId(name="TestAgent", key="test-key")
|
||||
thread = DurableAgentThread.from_session_id(session_id, service_thread_id="service-123")
|
||||
|
||||
assert thread.session_id is not None
|
||||
assert thread.session_id == session_id
|
||||
assert thread.service_thread_id == "service-123"
|
||||
|
||||
async def test_serialize_with_session_id(self) -> None:
|
||||
"""Test serialization includes session ID."""
|
||||
session_id = AgentSessionId(name="TestAgent", key="test-key")
|
||||
thread = DurableAgentThread(session_id=session_id)
|
||||
|
||||
serialized = await thread.serialize()
|
||||
|
||||
assert isinstance(serialized, dict)
|
||||
assert "durable_session_id" in serialized
|
||||
assert serialized["durable_session_id"] == "@TestAgent@test-key"
|
||||
|
||||
async def test_serialize_without_session_id(self) -> None:
|
||||
"""Test serialization without session ID."""
|
||||
thread = DurableAgentThread()
|
||||
|
||||
serialized = await thread.serialize()
|
||||
|
||||
assert isinstance(serialized, dict)
|
||||
assert "durable_session_id" not in serialized
|
||||
|
||||
async def test_deserialize_with_session_id(self) -> None:
|
||||
"""Test deserialization restores session ID."""
|
||||
serialized = {
|
||||
"service_thread_id": "thread-123",
|
||||
"durable_session_id": "@TestAgent@test-key",
|
||||
}
|
||||
|
||||
thread = await DurableAgentThread.deserialize(serialized)
|
||||
|
||||
assert isinstance(thread, DurableAgentThread)
|
||||
assert thread.session_id is not None
|
||||
assert thread.session_id.name == "TestAgent"
|
||||
assert thread.session_id.key == "test-key"
|
||||
assert thread.service_thread_id == "thread-123"
|
||||
|
||||
async def test_deserialize_without_session_id(self) -> None:
|
||||
"""Test deserialization without session ID."""
|
||||
serialized = {
|
||||
"service_thread_id": "thread-456",
|
||||
}
|
||||
|
||||
thread = await DurableAgentThread.deserialize(serialized)
|
||||
|
||||
assert isinstance(thread, DurableAgentThread)
|
||||
assert thread.session_id is None
|
||||
assert thread.service_thread_id == "thread-456"
|
||||
|
||||
async def test_round_trip_serialization(self) -> None:
|
||||
"""Test round-trip serialization preserves session ID."""
|
||||
session_id = AgentSessionId(name="TestAgent", key="test-key-789")
|
||||
original = DurableAgentThread(session_id=session_id)
|
||||
|
||||
serialized = await original.serialize()
|
||||
restored = await DurableAgentThread.deserialize(serialized)
|
||||
|
||||
assert isinstance(restored, DurableAgentThread)
|
||||
assert restored.session_id is not None
|
||||
assert restored.session_id.name == session_id.name
|
||||
assert restored.session_id.key == session_id.key
|
||||
|
||||
async def test_deserialize_invalid_session_id_type(self) -> None:
|
||||
"""Test deserialization with invalid session ID type raises error."""
|
||||
serialized = {
|
||||
"service_thread_id": "thread-123",
|
||||
"durable_session_id": 12345, # Invalid type
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="durable_session_id must be a string"):
|
||||
await DurableAgentThread.deserialize(serialized)
|
||||
|
||||
|
||||
class TestAgentThreadCompatibility:
|
||||
"""Test suite for compatibility between AgentThread and DurableAgentThread."""
|
||||
|
||||
async def test_agent_thread_serialize(self) -> None:
|
||||
"""Test that base AgentThread can be serialized."""
|
||||
thread = AgentThread()
|
||||
|
||||
serialized = await thread.serialize()
|
||||
|
||||
assert isinstance(serialized, dict)
|
||||
assert "service_thread_id" in serialized
|
||||
|
||||
async def test_agent_thread_deserialize(self) -> None:
|
||||
"""Test that base AgentThread can be deserialized."""
|
||||
thread = AgentThread()
|
||||
serialized = await thread.serialize()
|
||||
|
||||
restored = await AgentThread.deserialize(serialized)
|
||||
|
||||
assert isinstance(restored, AgentThread)
|
||||
assert restored.service_thread_id == thread.service_thread_id
|
||||
|
||||
async def test_durable_thread_is_agent_thread(self) -> None:
|
||||
"""Test that DurableAgentThread is an AgentThread."""
|
||||
thread = DurableAgentThread()
|
||||
|
||||
assert isinstance(thread, AgentThread)
|
||||
assert isinstance(thread, DurableAgentThread)
|
||||
|
||||
|
||||
class TestModelIntegration:
|
||||
"""Test suite for integration between models."""
|
||||
|
||||
def test_session_id_string_format(self) -> None:
|
||||
"""Test that AgentSessionId string format is consistent."""
|
||||
session_id = AgentSessionId.with_random_key("AgentEntity")
|
||||
session_id_str = str(session_id)
|
||||
|
||||
assert session_id_str.startswith("@AgentEntity@")
|
||||
|
||||
async def test_thread_with_session_preserves_on_serialization(self) -> None:
|
||||
"""Test that thread with session ID preserves it through serialization."""
|
||||
session_id = AgentSessionId(name="TestAgent", key="preserved-key")
|
||||
thread = DurableAgentThread.from_session_id(session_id)
|
||||
|
||||
# Serialize and deserialize
|
||||
serialized = await thread.serialize()
|
||||
restored = await DurableAgentThread.deserialize(serialized)
|
||||
|
||||
# Session ID should be preserved
|
||||
assert restored.session_id is not None
|
||||
assert restored.session_id.name == "TestAgent"
|
||||
assert restored.session_id.key == "preserved-key"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
@@ -0,0 +1,142 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for DurableAIAgentClient.
|
||||
|
||||
Focuses on critical client workflows: agent retrieval, protocol compliance, and integration.
|
||||
Run with: pytest tests/test_client.py -v
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from agent_framework import AgentProtocol
|
||||
|
||||
from agent_framework_durabletask import DurableAgentThread, DurableAIAgentClient
|
||||
from agent_framework_durabletask._constants import DEFAULT_MAX_POLL_RETRIES, DEFAULT_POLL_INTERVAL_SECONDS
|
||||
from agent_framework_durabletask._shim import DurableAIAgent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_grpc_client() -> Mock:
|
||||
"""Create a mock TaskHubGrpcClient for testing."""
|
||||
return Mock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent_client(mock_grpc_client: Mock) -> DurableAIAgentClient:
|
||||
"""Create a DurableAIAgentClient with mock gRPC client."""
|
||||
return DurableAIAgentClient(mock_grpc_client)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent_client_with_custom_polling(mock_grpc_client: Mock) -> DurableAIAgentClient:
|
||||
"""Create a DurableAIAgentClient with custom polling parameters."""
|
||||
return DurableAIAgentClient(
|
||||
mock_grpc_client,
|
||||
max_poll_retries=15,
|
||||
poll_interval_seconds=0.5,
|
||||
)
|
||||
|
||||
|
||||
class TestDurableAIAgentClientGetAgent:
|
||||
"""Test core workflow: retrieving agents from the client."""
|
||||
|
||||
def test_get_agent_returns_durable_agent_shim(self, agent_client: DurableAIAgentClient) -> None:
|
||||
"""Verify get_agent returns a DurableAIAgent instance."""
|
||||
agent = agent_client.get_agent("assistant")
|
||||
|
||||
assert isinstance(agent, DurableAIAgent)
|
||||
assert isinstance(agent, AgentProtocol)
|
||||
|
||||
def test_get_agent_shim_has_correct_name(self, agent_client: DurableAIAgentClient) -> None:
|
||||
"""Verify retrieved agent has the correct name."""
|
||||
agent = agent_client.get_agent("my_agent")
|
||||
|
||||
assert agent.name == "my_agent"
|
||||
|
||||
def test_get_agent_multiple_times_returns_new_instances(self, agent_client: DurableAIAgentClient) -> None:
|
||||
"""Verify multiple get_agent calls return independent instances."""
|
||||
agent1 = agent_client.get_agent("assistant")
|
||||
agent2 = agent_client.get_agent("assistant")
|
||||
|
||||
assert agent1 is not agent2 # Different object instances
|
||||
|
||||
def test_get_agent_different_agents(self, agent_client: DurableAIAgentClient) -> None:
|
||||
"""Verify client can retrieve multiple different agents."""
|
||||
agent1 = agent_client.get_agent("agent1")
|
||||
agent2 = agent_client.get_agent("agent2")
|
||||
|
||||
assert agent1.name == "agent1"
|
||||
assert agent2.name == "agent2"
|
||||
|
||||
|
||||
class TestDurableAIAgentClientIntegration:
|
||||
"""Test integration scenarios between client and agent shim."""
|
||||
|
||||
def test_client_agent_has_working_run_method(self, agent_client: DurableAIAgentClient) -> None:
|
||||
"""Verify agent from client has callable run method (even if not yet implemented)."""
|
||||
agent = agent_client.get_agent("assistant")
|
||||
|
||||
assert hasattr(agent, "run")
|
||||
assert callable(agent.run)
|
||||
|
||||
def test_client_agent_can_create_threads(self, agent_client: DurableAIAgentClient) -> None:
|
||||
"""Verify agent from client can create DurableAgentThread instances."""
|
||||
agent = agent_client.get_agent("assistant")
|
||||
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
assert isinstance(thread, DurableAgentThread)
|
||||
|
||||
def test_client_agent_thread_with_parameters(self, agent_client: DurableAIAgentClient) -> None:
|
||||
"""Verify agent can create threads with custom parameters."""
|
||||
agent = agent_client.get_agent("assistant")
|
||||
|
||||
thread = agent.get_new_thread(service_thread_id="client-session-123")
|
||||
|
||||
assert isinstance(thread, DurableAgentThread)
|
||||
assert thread.service_thread_id == "client-session-123"
|
||||
|
||||
|
||||
class TestDurableAIAgentClientPollingConfiguration:
|
||||
"""Test polling configuration parameters for DurableAIAgentClient."""
|
||||
|
||||
def test_client_uses_default_polling_parameters(self, agent_client: DurableAIAgentClient) -> None:
|
||||
"""Verify client initializes with default polling parameters."""
|
||||
assert agent_client.max_poll_retries == DEFAULT_MAX_POLL_RETRIES
|
||||
assert agent_client.poll_interval_seconds == DEFAULT_POLL_INTERVAL_SECONDS
|
||||
|
||||
def test_client_accepts_custom_polling_parameters(
|
||||
self, agent_client_with_custom_polling: DurableAIAgentClient
|
||||
) -> None:
|
||||
"""Verify client accepts and stores custom polling parameters."""
|
||||
assert agent_client_with_custom_polling.max_poll_retries == 15
|
||||
assert agent_client_with_custom_polling.poll_interval_seconds == 0.5
|
||||
|
||||
def test_client_validates_max_poll_retries(self, mock_grpc_client: Mock) -> None:
|
||||
"""Verify client validates and normalizes max_poll_retries."""
|
||||
# Test with zero - should enforce minimum of 1
|
||||
client = DurableAIAgentClient(mock_grpc_client, max_poll_retries=0)
|
||||
assert client.max_poll_retries == 1
|
||||
|
||||
# Test with negative - should enforce minimum of 1
|
||||
client = DurableAIAgentClient(mock_grpc_client, max_poll_retries=-5)
|
||||
assert client.max_poll_retries == 1
|
||||
|
||||
def test_client_validates_poll_interval_seconds(self, mock_grpc_client: Mock) -> None:
|
||||
"""Verify client validates and normalizes poll_interval_seconds."""
|
||||
# Test with zero - should use default
|
||||
client = DurableAIAgentClient(mock_grpc_client, poll_interval_seconds=0)
|
||||
assert client.poll_interval_seconds == DEFAULT_POLL_INTERVAL_SECONDS
|
||||
|
||||
# Test with negative - should use default
|
||||
client = DurableAIAgentClient(mock_grpc_client, poll_interval_seconds=-0.5)
|
||||
assert client.poll_interval_seconds == DEFAULT_POLL_INTERVAL_SECONDS
|
||||
|
||||
# Test with valid float
|
||||
client = DurableAIAgentClient(mock_grpc_client, poll_interval_seconds=2.5)
|
||||
assert client.poll_interval_seconds == 2.5
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
@@ -0,0 +1,217 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for DurableAgentState and related classes."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework_durabletask._durable_agent_state import (
|
||||
DurableAgentState,
|
||||
DurableAgentStateMessage,
|
||||
DurableAgentStateRequest,
|
||||
DurableAgentStateTextContent,
|
||||
)
|
||||
from agent_framework_durabletask._models import RunRequest
|
||||
|
||||
|
||||
class TestDurableAgentStateRequestOrchestrationId:
|
||||
"""Test suite for DurableAgentStateRequest orchestration_id field."""
|
||||
|
||||
def test_request_with_orchestration_id(self) -> None:
|
||||
"""Test creating a request with an orchestration_id."""
|
||||
request = DurableAgentStateRequest(
|
||||
correlation_id="corr-123",
|
||||
created_at=datetime.now(),
|
||||
messages=[
|
||||
DurableAgentStateMessage(
|
||||
role="user",
|
||||
contents=[DurableAgentStateTextContent(text="test")],
|
||||
)
|
||||
],
|
||||
orchestration_id="orch-456",
|
||||
)
|
||||
|
||||
assert request.orchestration_id == "orch-456"
|
||||
|
||||
def test_request_to_dict_includes_orchestration_id(self) -> None:
|
||||
"""Test that to_dict includes orchestrationId when set."""
|
||||
request = DurableAgentStateRequest(
|
||||
correlation_id="corr-123",
|
||||
created_at=datetime.now(),
|
||||
messages=[
|
||||
DurableAgentStateMessage(
|
||||
role="user",
|
||||
contents=[DurableAgentStateTextContent(text="test")],
|
||||
)
|
||||
],
|
||||
orchestration_id="orch-789",
|
||||
)
|
||||
|
||||
data = request.to_dict()
|
||||
|
||||
assert "orchestrationId" in data
|
||||
assert data["orchestrationId"] == "orch-789"
|
||||
|
||||
def test_request_to_dict_excludes_orchestration_id_when_none(self) -> None:
|
||||
"""Test that to_dict excludes orchestrationId when not set."""
|
||||
request = DurableAgentStateRequest(
|
||||
correlation_id="corr-123",
|
||||
created_at=datetime.now(),
|
||||
messages=[
|
||||
DurableAgentStateMessage(
|
||||
role="user",
|
||||
contents=[DurableAgentStateTextContent(text="test")],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
data = request.to_dict()
|
||||
|
||||
assert "orchestrationId" not in data
|
||||
|
||||
def test_request_from_dict_with_orchestration_id(self) -> None:
|
||||
"""Test from_dict correctly parses orchestrationId."""
|
||||
data = {
|
||||
"$type": "request",
|
||||
"correlationId": "corr-123",
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"messages": [{"role": "user", "contents": [{"$type": "text", "text": "test"}]}],
|
||||
"orchestrationId": "orch-from-dict",
|
||||
}
|
||||
|
||||
request = DurableAgentStateRequest.from_dict(data)
|
||||
|
||||
assert request.orchestration_id == "orch-from-dict"
|
||||
|
||||
def test_request_from_run_request_with_orchestration_id(self) -> None:
|
||||
"""Test from_run_request correctly transfers orchestration_id."""
|
||||
run_request = RunRequest(
|
||||
message="test message",
|
||||
correlation_id="corr-run",
|
||||
orchestration_id="orch-from-run-request",
|
||||
)
|
||||
|
||||
durable_request = DurableAgentStateRequest.from_run_request(run_request)
|
||||
|
||||
assert durable_request.orchestration_id == "orch-from-run-request"
|
||||
|
||||
def test_request_from_run_request_without_orchestration_id(self) -> None:
|
||||
"""Test from_run_request correctly handles missing orchestration_id."""
|
||||
run_request = RunRequest(
|
||||
message="test message",
|
||||
correlation_id="corr-run",
|
||||
)
|
||||
|
||||
durable_request = DurableAgentStateRequest.from_run_request(run_request)
|
||||
|
||||
assert durable_request.orchestration_id is None
|
||||
|
||||
|
||||
class TestDurableAgentStateMessageCreatedAt:
|
||||
"""Test suite for DurableAgentStateMessage created_at field handling."""
|
||||
|
||||
def test_message_from_run_request_without_created_at_preserves_none(self) -> None:
|
||||
"""Test from_run_request handles auto-populated created_at from RunRequest.
|
||||
|
||||
When a RunRequest is created with None for created_at, RunRequest defaults it to
|
||||
current UTC time. The resulting DurableAgentStateMessage should have this timestamp.
|
||||
"""
|
||||
run_request = RunRequest(
|
||||
message="test message",
|
||||
correlation_id="corr-run",
|
||||
created_at=None, # RunRequest will default this to current time
|
||||
)
|
||||
|
||||
durable_message = DurableAgentStateMessage.from_run_request(run_request)
|
||||
|
||||
# RunRequest auto-populates created_at, so it should not be None
|
||||
assert durable_message.created_at is not None
|
||||
|
||||
def test_message_from_run_request_with_created_at_parses_correctly(self) -> None:
|
||||
"""Test from_run_request correctly parses a valid created_at timestamp."""
|
||||
run_request = RunRequest(
|
||||
message="test message",
|
||||
correlation_id="corr-run",
|
||||
created_at=datetime(2024, 1, 15, 10, 30, 0),
|
||||
)
|
||||
|
||||
durable_message = DurableAgentStateMessage.from_run_request(run_request)
|
||||
|
||||
assert durable_message.created_at is not None
|
||||
assert durable_message.created_at.year == 2024
|
||||
assert durable_message.created_at.month == 1
|
||||
assert durable_message.created_at.day == 15
|
||||
|
||||
|
||||
class TestDurableAgentState:
|
||||
"""Test suite for DurableAgentState."""
|
||||
|
||||
def test_schema_version(self) -> None:
|
||||
"""Test that schema version is set correctly."""
|
||||
state = DurableAgentState()
|
||||
assert state.schema_version == "1.1.0"
|
||||
|
||||
def test_to_dict_serialization(self) -> None:
|
||||
"""Test that to_dict produces correct structure."""
|
||||
state = DurableAgentState()
|
||||
data = state.to_dict()
|
||||
|
||||
assert "schemaVersion" in data
|
||||
assert "data" in data
|
||||
assert data["schemaVersion"] == "1.1.0"
|
||||
assert "conversationHistory" in data["data"]
|
||||
|
||||
def test_from_dict_deserialization(self) -> None:
|
||||
"""Test that from_dict restores state correctly."""
|
||||
original_data = {
|
||||
"schemaVersion": "1.1.0",
|
||||
"data": {
|
||||
"conversationHistory": [
|
||||
{
|
||||
"$type": "request",
|
||||
"correlationId": "test-123",
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"contents": [{"$type": "text", "text": "Hello"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
state = DurableAgentState.from_dict(original_data)
|
||||
|
||||
assert state.schema_version == "1.1.0"
|
||||
assert len(state.data.conversation_history) == 1
|
||||
assert isinstance(state.data.conversation_history[0], DurableAgentStateRequest)
|
||||
|
||||
def test_round_trip_serialization(self) -> None:
|
||||
"""Test that round-trip serialization preserves data."""
|
||||
state = DurableAgentState()
|
||||
state.data.conversation_history.append(
|
||||
DurableAgentStateRequest(
|
||||
correlation_id="test-456",
|
||||
created_at=datetime.now(),
|
||||
messages=[
|
||||
DurableAgentStateMessage(
|
||||
role="user",
|
||||
contents=[DurableAgentStateTextContent(text="Test message")],
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
data = state.to_dict()
|
||||
restored = DurableAgentState.from_dict(data)
|
||||
|
||||
assert restored.schema_version == state.schema_version
|
||||
assert len(restored.data.conversation_history) == len(state.data.conversation_history)
|
||||
assert restored.data.conversation_history[0].correlation_id == "test-456"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
@@ -0,0 +1,695 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for AgentEntity.
|
||||
|
||||
Run with: pytest tests/test_entities.py -v
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime
|
||||
from typing import Any, TypeVar
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from agent_framework import AgentResponse, AgentResponseUpdate, ChatMessage, Content, Role
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework_durabletask import (
|
||||
AgentEntity,
|
||||
AgentEntityStateProviderMixin,
|
||||
DurableAgentState,
|
||||
DurableAgentStateData,
|
||||
DurableAgentStateMessage,
|
||||
DurableAgentStateRequest,
|
||||
DurableAgentStateTextContent,
|
||||
RunRequest,
|
||||
)
|
||||
from agent_framework_durabletask._entities import DurableTaskEntityStateProvider
|
||||
|
||||
TState = TypeVar("TState")
|
||||
|
||||
|
||||
class MockEntityContext:
|
||||
"""Minimal durabletask EntityContext shim for tests."""
|
||||
|
||||
def __init__(self, initial_state: Any = None) -> None:
|
||||
self._state = initial_state
|
||||
|
||||
def get_state(
|
||||
self,
|
||||
intended_type: type[TState] | None = None,
|
||||
default: TState | None = None,
|
||||
) -> Any:
|
||||
del intended_type
|
||||
if self._state is None:
|
||||
return default
|
||||
return self._state
|
||||
|
||||
def set_state(self, new_state: Any) -> None:
|
||||
self._state = new_state
|
||||
|
||||
|
||||
class _InMemoryStateProvider(AgentEntityStateProviderMixin):
|
||||
"""Test-only state provider for AgentEntity."""
|
||||
|
||||
def __init__(self, *, thread_id: str, initial_state: dict[str, Any] | None = None) -> None:
|
||||
self._thread_id = thread_id
|
||||
self._state_dict: dict[str, Any] = initial_state or {}
|
||||
|
||||
def _get_state_dict(self) -> dict[str, Any]:
|
||||
return self._state_dict
|
||||
|
||||
def _set_state_dict(self, state: dict[str, Any]) -> None:
|
||||
self._state_dict = state
|
||||
|
||||
def _get_thread_id_from_entity(self) -> str:
|
||||
return self._thread_id
|
||||
|
||||
|
||||
def _make_entity(agent: Any, callback: Any = None, *, thread_id: str = "test-thread") -> AgentEntity:
|
||||
return AgentEntity(agent, callback=callback, state_provider=_InMemoryStateProvider(thread_id=thread_id))
|
||||
|
||||
|
||||
def _role_value(chat_message: DurableAgentStateMessage) -> str:
|
||||
"""Helper to extract the string role from a ChatMessage."""
|
||||
role = getattr(chat_message, "role", None)
|
||||
role_value = getattr(role, "value", role)
|
||||
if role_value is None:
|
||||
return ""
|
||||
return str(role_value)
|
||||
|
||||
|
||||
def _agent_response(text: str | None) -> AgentResponse:
|
||||
"""Create an AgentResponse with a single assistant message."""
|
||||
message = (
|
||||
ChatMessage(role="assistant", text=text) if text is not None else ChatMessage(role="assistant", contents=[])
|
||||
)
|
||||
return AgentResponse(messages=[message])
|
||||
|
||||
|
||||
class RecordingCallback:
|
||||
"""Callback implementation capturing streaming and final responses for assertions."""
|
||||
|
||||
def __init__(self):
|
||||
self.stream_mock = AsyncMock()
|
||||
self.response_mock = AsyncMock()
|
||||
|
||||
async def on_streaming_response_update(
|
||||
self,
|
||||
update: AgentResponseUpdate,
|
||||
context: Any,
|
||||
) -> None:
|
||||
await self.stream_mock(update, context)
|
||||
|
||||
async def on_agent_response(self, response: AgentResponse, context: Any) -> None:
|
||||
await self.response_mock(response, context)
|
||||
|
||||
|
||||
class EntityStructuredResponse(BaseModel):
|
||||
answer: float
|
||||
|
||||
|
||||
class TestAgentEntityInit:
|
||||
"""Test suite for AgentEntity initialization."""
|
||||
|
||||
def test_init_creates_entity(self) -> None:
|
||||
"""Test that AgentEntity initializes correctly."""
|
||||
mock_agent = Mock()
|
||||
|
||||
entity = _make_entity(mock_agent)
|
||||
|
||||
assert entity.agent == mock_agent
|
||||
assert len(entity.state.data.conversation_history) == 0
|
||||
assert entity.state.data.extension_data is None
|
||||
assert entity.state.schema_version == DurableAgentState.SCHEMA_VERSION
|
||||
|
||||
def test_init_stores_agent_reference(self) -> None:
|
||||
"""Test that the agent reference is stored correctly."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "TestAgent"
|
||||
|
||||
entity = _make_entity(mock_agent)
|
||||
|
||||
assert entity.agent.name == "TestAgent"
|
||||
|
||||
def test_init_with_different_agent_types(self) -> None:
|
||||
"""Test initialization with different agent types."""
|
||||
agent1 = Mock()
|
||||
agent1.__class__.__name__ = "AzureOpenAIAgent"
|
||||
|
||||
agent2 = Mock()
|
||||
agent2.__class__.__name__ = "CustomAgent"
|
||||
|
||||
entity1 = _make_entity(agent1)
|
||||
entity2 = _make_entity(agent2)
|
||||
|
||||
assert entity1.agent.__class__.__name__ == "AzureOpenAIAgent"
|
||||
assert entity2.agent.__class__.__name__ == "CustomAgent"
|
||||
|
||||
|
||||
class TestDurableTaskEntityStateProvider:
|
||||
"""Tests for DurableTaskEntityStateProvider wrapper behavior and persistence wiring."""
|
||||
|
||||
def _make_durabletask_entity_provider(
|
||||
self,
|
||||
agent: Any,
|
||||
*,
|
||||
initial_state: dict[str, Any] | None = None,
|
||||
) -> tuple[DurableTaskEntityStateProvider, MockEntityContext]:
|
||||
"""Create a DurableTaskEntityStateProvider wired to an in-memory durabletask context."""
|
||||
entity = DurableTaskEntityStateProvider()
|
||||
ctx = MockEntityContext(initial_state)
|
||||
# DurableEntity provides this hook; required for get_state/set_state to work in unit tests.
|
||||
entity._initialize_entity_context(ctx) # type: ignore[attr-defined]
|
||||
return entity, ctx
|
||||
|
||||
def test_reset_persists_cleared_state(self) -> None:
|
||||
mock_agent = Mock()
|
||||
|
||||
existing_state = {
|
||||
"schemaVersion": "1.0.0",
|
||||
"data": {
|
||||
"conversationHistory": [
|
||||
{
|
||||
"$type": "request",
|
||||
"correlationId": "corr-existing-1",
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"messages": [{"role": "user", "contents": [{"$type": "text", "text": "msg1"}]}],
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
entity, ctx = self._make_durabletask_entity_provider(mock_agent, initial_state=existing_state)
|
||||
|
||||
entity.reset()
|
||||
|
||||
persisted = ctx.get_state(dict, default={})
|
||||
assert isinstance(persisted, dict)
|
||||
assert persisted["data"]["conversationHistory"] == []
|
||||
|
||||
|
||||
class TestAgentEntityRunAgent:
|
||||
"""Test suite for the run_agent operation."""
|
||||
|
||||
async def test_run_executes_agent(self) -> None:
|
||||
"""Test that run executes the agent."""
|
||||
mock_agent = Mock()
|
||||
mock_response = _agent_response("Test response")
|
||||
mock_agent.run = AsyncMock(return_value=mock_response)
|
||||
|
||||
entity = _make_entity(mock_agent)
|
||||
|
||||
result = await entity.run({
|
||||
"message": "Test message",
|
||||
"correlationId": "corr-entity-1",
|
||||
})
|
||||
|
||||
# Verify agent.run was called
|
||||
mock_agent.run.assert_called_once()
|
||||
_, kwargs = mock_agent.run.call_args
|
||||
sent_messages: list[Any] = kwargs.get("messages")
|
||||
assert len(sent_messages) == 1
|
||||
sent_message = sent_messages[0]
|
||||
assert isinstance(sent_message, ChatMessage)
|
||||
assert getattr(sent_message, "text", None) == "Test message"
|
||||
assert getattr(sent_message.role, "value", sent_message.role) == "user"
|
||||
|
||||
# Verify result
|
||||
assert isinstance(result, AgentResponse)
|
||||
assert result.text == "Test response"
|
||||
|
||||
async def test_run_agent_streaming_callbacks_invoked(self) -> None:
|
||||
"""Ensure streaming updates trigger callbacks and run() is not used."""
|
||||
updates = [
|
||||
AgentResponseUpdate(text="Hello"),
|
||||
AgentResponseUpdate(text=" world"),
|
||||
]
|
||||
|
||||
async def update_generator() -> AsyncIterator[AgentResponseUpdate]:
|
||||
for update in updates:
|
||||
yield update
|
||||
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "StreamingAgent"
|
||||
mock_agent.run_stream = Mock(return_value=update_generator())
|
||||
mock_agent.run = AsyncMock(side_effect=AssertionError("run() should not be called when streaming succeeds"))
|
||||
|
||||
callback = RecordingCallback()
|
||||
entity = _make_entity(mock_agent, callback=callback, thread_id="session-1")
|
||||
|
||||
result = await entity.run(
|
||||
{
|
||||
"message": "Tell me something",
|
||||
"correlationId": "corr-stream-1",
|
||||
},
|
||||
)
|
||||
|
||||
assert isinstance(result, AgentResponse)
|
||||
assert "Hello" in result.text
|
||||
assert callback.stream_mock.await_count == len(updates)
|
||||
assert callback.response_mock.await_count == 1
|
||||
mock_agent.run.assert_not_called()
|
||||
|
||||
# Validate callback arguments
|
||||
stream_calls = callback.stream_mock.await_args_list
|
||||
for expected_update, recorded_call in zip(updates, stream_calls, strict=True):
|
||||
assert recorded_call.args[0] is expected_update
|
||||
context = recorded_call.args[1]
|
||||
assert context.agent_name == "StreamingAgent"
|
||||
assert context.correlation_id == "corr-stream-1"
|
||||
assert context.thread_id == "session-1"
|
||||
assert context.request_message == "Tell me something"
|
||||
|
||||
final_call = callback.response_mock.await_args
|
||||
assert final_call is not None
|
||||
final_response, final_context = final_call.args
|
||||
assert final_context.agent_name == "StreamingAgent"
|
||||
assert final_context.correlation_id == "corr-stream-1"
|
||||
assert final_context.thread_id == "session-1"
|
||||
assert final_context.request_message == "Tell me something"
|
||||
assert getattr(final_response, "text", "").strip()
|
||||
|
||||
async def test_run_agent_final_callback_without_streaming(self) -> None:
|
||||
"""Ensure the final callback fires even when streaming is unavailable."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "NonStreamingAgent"
|
||||
mock_agent.run_stream = None
|
||||
agent_response = _agent_response("Final response")
|
||||
mock_agent.run = AsyncMock(return_value=agent_response)
|
||||
|
||||
callback = RecordingCallback()
|
||||
entity = _make_entity(mock_agent, callback=callback, thread_id="session-2")
|
||||
|
||||
result = await entity.run(
|
||||
{
|
||||
"message": "Hi",
|
||||
"correlationId": "corr-final-1",
|
||||
},
|
||||
)
|
||||
|
||||
assert isinstance(result, AgentResponse)
|
||||
assert result.text == "Final response"
|
||||
assert callback.stream_mock.await_count == 0
|
||||
assert callback.response_mock.await_count == 1
|
||||
|
||||
final_call = callback.response_mock.await_args
|
||||
assert final_call is not None
|
||||
assert final_call.args[0] is agent_response
|
||||
final_context = final_call.args[1]
|
||||
assert final_context.agent_name == "NonStreamingAgent"
|
||||
assert final_context.correlation_id == "corr-final-1"
|
||||
assert final_context.thread_id == "session-2"
|
||||
assert final_context.request_message == "Hi"
|
||||
|
||||
async def test_run_agent_updates_conversation_history(self) -> None:
|
||||
"""Test that run_agent updates the conversation history."""
|
||||
mock_agent = Mock()
|
||||
mock_response = _agent_response("Agent response")
|
||||
mock_agent.run = AsyncMock(return_value=mock_response)
|
||||
|
||||
entity = _make_entity(mock_agent)
|
||||
|
||||
await entity.run({"message": "User message", "correlationId": "corr-entity-2"})
|
||||
|
||||
# Should have 2 entries: user message + assistant response
|
||||
user_history = entity.state.data.conversation_history[0].messages
|
||||
assistant_history = entity.state.data.conversation_history[1].messages
|
||||
|
||||
assert len(user_history) == 1
|
||||
|
||||
user_msg = user_history[0]
|
||||
assert _role_value(user_msg) == "user"
|
||||
assert user_msg.text == "User message"
|
||||
|
||||
assistant_msg = assistant_history[0]
|
||||
assert _role_value(assistant_msg) == "assistant"
|
||||
assert assistant_msg.text == "Agent response"
|
||||
|
||||
async def test_run_agent_increments_message_count(self) -> None:
|
||||
"""Test that run_agent increments the message count."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = _make_entity(mock_agent)
|
||||
|
||||
assert len(entity.state.data.conversation_history) == 0
|
||||
|
||||
await entity.run({"message": "Message 1", "correlationId": "corr-entity-3a"})
|
||||
assert len(entity.state.data.conversation_history) == 2
|
||||
|
||||
await entity.run({"message": "Message 2", "correlationId": "corr-entity-3b"})
|
||||
assert len(entity.state.data.conversation_history) == 4
|
||||
|
||||
await entity.run({"message": "Message 3", "correlationId": "corr-entity-3c"})
|
||||
assert len(entity.state.data.conversation_history) == 6
|
||||
|
||||
async def test_run_requires_entity_thread_id(self) -> None:
|
||||
"""Test that AgentEntity.run rejects missing entity thread identifiers."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = _make_entity(mock_agent, thread_id="")
|
||||
|
||||
with pytest.raises(ValueError, match="thread_id"):
|
||||
await entity.run({"message": "Message", "correlationId": "corr-entity-5"})
|
||||
|
||||
async def test_run_agent_multiple_conversations(self) -> None:
|
||||
"""Test that run_agent maintains history across multiple messages."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = _make_entity(mock_agent)
|
||||
|
||||
# Send multiple messages
|
||||
await entity.run({"message": "Message 1", "correlationId": "corr-entity-8a"})
|
||||
await entity.run({"message": "Message 2", "correlationId": "corr-entity-8b"})
|
||||
await entity.run({"message": "Message 3", "correlationId": "corr-entity-8c"})
|
||||
|
||||
history = entity.state.data.conversation_history
|
||||
assert len(history) == 6
|
||||
assert entity.state.message_count == 6
|
||||
|
||||
|
||||
class TestAgentEntityReset:
|
||||
"""Test suite for the reset operation."""
|
||||
|
||||
def test_reset_clears_conversation_history(self) -> None:
|
||||
"""Test that reset clears the conversation history."""
|
||||
mock_agent = Mock()
|
||||
entity = _make_entity(mock_agent)
|
||||
|
||||
# Add some history with proper DurableAgentStateEntry objects
|
||||
entity.state.data.conversation_history = [
|
||||
DurableAgentStateRequest(
|
||||
correlation_id="test-1",
|
||||
created_at=datetime.now(),
|
||||
messages=[
|
||||
DurableAgentStateMessage(
|
||||
role="user",
|
||||
contents=[DurableAgentStateTextContent(text="msg1")],
|
||||
)
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
entity.reset()
|
||||
|
||||
assert entity.state.data.conversation_history == []
|
||||
|
||||
def test_reset_with_extension_data(self) -> None:
|
||||
"""Test that reset works when entity has extension data."""
|
||||
mock_agent = Mock()
|
||||
entity = _make_entity(mock_agent)
|
||||
|
||||
# Set up some initial state with conversation history
|
||||
entity.state.data = DurableAgentStateData(conversation_history=[], extension_data={"some_key": "some_value"})
|
||||
|
||||
entity.reset()
|
||||
|
||||
assert len(entity.state.data.conversation_history) == 0
|
||||
|
||||
def test_reset_clears_message_count(self) -> None:
|
||||
"""Test that reset clears the message count."""
|
||||
mock_agent = Mock()
|
||||
entity = _make_entity(mock_agent)
|
||||
|
||||
entity.reset()
|
||||
|
||||
assert len(entity.state.data.conversation_history) == 0
|
||||
|
||||
async def test_reset_after_conversation(self) -> None:
|
||||
"""Test reset after a full conversation."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = _make_entity(mock_agent)
|
||||
|
||||
# Have a conversation
|
||||
await entity.run({"message": "Message 1", "correlationId": "corr-entity-10a"})
|
||||
await entity.run({"message": "Message 2", "correlationId": "corr-entity-10b"})
|
||||
|
||||
# Verify state before reset
|
||||
assert entity.state.message_count == 4
|
||||
assert len(entity.state.data.conversation_history) == 4
|
||||
|
||||
# Reset
|
||||
entity.reset()
|
||||
|
||||
# Verify state after reset
|
||||
assert entity.state.message_count == 0
|
||||
assert len(entity.state.data.conversation_history) == 0
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
"""Test suite for error handling in entities."""
|
||||
|
||||
async def test_run_agent_handles_agent_exception(self) -> None:
|
||||
"""Test that run_agent handles agent exceptions."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(side_effect=Exception("Agent failed"))
|
||||
|
||||
entity = _make_entity(mock_agent)
|
||||
|
||||
result = await entity.run({"message": "Message", "correlationId": "corr-entity-error-1"})
|
||||
|
||||
assert isinstance(result, AgentResponse)
|
||||
assert len(result.messages) == 1
|
||||
content = result.messages[0].contents[0]
|
||||
assert isinstance(content, Content)
|
||||
assert "Agent failed" in (content.message or "")
|
||||
assert content.error_code == "Exception"
|
||||
|
||||
async def test_run_agent_handles_value_error(self) -> None:
|
||||
"""Test that run_agent handles ValueError instances."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(side_effect=ValueError("Invalid input"))
|
||||
|
||||
entity = _make_entity(mock_agent)
|
||||
|
||||
result = await entity.run({"message": "Message", "correlationId": "corr-entity-error-2"})
|
||||
|
||||
assert isinstance(result, AgentResponse)
|
||||
assert len(result.messages) == 1
|
||||
content = result.messages[0].contents[0]
|
||||
assert isinstance(content, Content)
|
||||
assert content.error_code == "ValueError"
|
||||
assert "Invalid input" in str(content.message)
|
||||
|
||||
async def test_run_agent_handles_timeout_error(self) -> None:
|
||||
"""Test that run_agent handles TimeoutError instances."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(side_effect=TimeoutError("Request timeout"))
|
||||
|
||||
entity = _make_entity(mock_agent)
|
||||
|
||||
result = await entity.run({"message": "Message", "correlationId": "corr-entity-error-3"})
|
||||
|
||||
assert isinstance(result, AgentResponse)
|
||||
assert len(result.messages) == 1
|
||||
content = result.messages[0].contents[0]
|
||||
assert isinstance(content, Content)
|
||||
assert content.error_code == "TimeoutError"
|
||||
|
||||
async def test_run_agent_preserves_message_on_error(self) -> None:
|
||||
"""Test that run_agent preserves message information on error."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(side_effect=Exception("Error"))
|
||||
|
||||
entity = _make_entity(mock_agent)
|
||||
|
||||
result = await entity.run(
|
||||
{"message": "Test message", "correlationId": "corr-entity-error-4"},
|
||||
)
|
||||
|
||||
# Even on error, message info should be preserved
|
||||
assert isinstance(result, AgentResponse)
|
||||
assert len(result.messages) == 1
|
||||
content = result.messages[0].contents[0]
|
||||
assert isinstance(content, Content)
|
||||
|
||||
|
||||
class TestConversationHistory:
|
||||
"""Test suite for conversation history tracking."""
|
||||
|
||||
async def test_conversation_history_has_timestamps(self) -> None:
|
||||
"""Test that conversation history entries include timestamps."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = _make_entity(mock_agent)
|
||||
|
||||
await entity.run({"message": "Message", "correlationId": "corr-entity-history-1"})
|
||||
|
||||
# Check both user and assistant messages have timestamps
|
||||
for entry in entity.state.data.conversation_history:
|
||||
timestamp = entry.created_at
|
||||
assert timestamp is not None
|
||||
# Verify timestamp is in ISO format
|
||||
datetime.fromisoformat(str(timestamp))
|
||||
|
||||
async def test_conversation_history_ordering(self) -> None:
|
||||
"""Test that conversation history maintains the correct order."""
|
||||
mock_agent = Mock()
|
||||
|
||||
entity = _make_entity(mock_agent)
|
||||
|
||||
# Send multiple messages with different responses
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response 1"))
|
||||
await entity.run(
|
||||
{"message": "Message 1", "correlationId": "corr-entity-history-2a"},
|
||||
)
|
||||
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response 2"))
|
||||
await entity.run(
|
||||
{"message": "Message 2", "correlationId": "corr-entity-history-2b"},
|
||||
)
|
||||
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response 3"))
|
||||
await entity.run(
|
||||
{"message": "Message 3", "correlationId": "corr-entity-history-2c"},
|
||||
)
|
||||
|
||||
# Verify order
|
||||
history = entity.state.data.conversation_history
|
||||
# Each conversation turn creates 2 entries: request and response
|
||||
assert history[0].messages[0].text == "Message 1" # Request 1
|
||||
assert history[1].messages[0].text == "Response 1" # Response 1
|
||||
assert history[2].messages[0].text == "Message 2" # Request 2
|
||||
assert history[3].messages[0].text == "Response 2" # Response 2
|
||||
assert history[4].messages[0].text == "Message 3" # Request 3
|
||||
assert history[5].messages[0].text == "Response 3" # Response 3
|
||||
|
||||
async def test_conversation_history_role_alternation(self) -> None:
|
||||
"""Test that conversation history alternates between user and assistant roles."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = _make_entity(mock_agent)
|
||||
|
||||
await entity.run(
|
||||
{"message": "Message 1", "correlationId": "corr-entity-history-3a"},
|
||||
)
|
||||
await entity.run(
|
||||
{"message": "Message 2", "correlationId": "corr-entity-history-3b"},
|
||||
)
|
||||
|
||||
# Check role alternation
|
||||
history = entity.state.data.conversation_history
|
||||
# Each conversation turn creates 2 entries: request and response
|
||||
assert history[0].messages[0].role == "user" # Request 1
|
||||
assert history[1].messages[0].role == "assistant" # Response 1
|
||||
assert history[2].messages[0].role == "user" # Request 2
|
||||
assert history[3].messages[0].role == "assistant" # Response 2
|
||||
|
||||
|
||||
class TestRunRequestSupport:
|
||||
"""Test suite for RunRequest support in entities."""
|
||||
|
||||
async def test_run_agent_with_run_request_object(self) -> None:
|
||||
"""Test run_agent with a RunRequest object."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = _make_entity(mock_agent)
|
||||
|
||||
request = RunRequest(
|
||||
message="Test message",
|
||||
role=Role.USER,
|
||||
enable_tool_calls=True,
|
||||
correlation_id="corr-runreq-1",
|
||||
)
|
||||
|
||||
result = await entity.run(request)
|
||||
|
||||
assert isinstance(result, AgentResponse)
|
||||
assert result.text == "Response"
|
||||
|
||||
async def test_run_agent_with_dict_request(self) -> None:
|
||||
"""Test run_agent with a dictionary request."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = _make_entity(mock_agent)
|
||||
|
||||
request_dict = {
|
||||
"message": "Test message",
|
||||
"role": "system",
|
||||
"enable_tool_calls": False,
|
||||
"correlationId": "corr-runreq-2",
|
||||
}
|
||||
|
||||
result = await entity.run(request_dict)
|
||||
|
||||
assert isinstance(result, AgentResponse)
|
||||
assert result.text == "Response"
|
||||
|
||||
async def test_run_agent_with_string_raises_without_correlation(self) -> None:
|
||||
"""Test that run_agent rejects legacy string input without correlation ID."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = _make_entity(mock_agent)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await entity.run("Simple message")
|
||||
|
||||
async def test_run_agent_stores_role_in_history(self) -> None:
|
||||
"""Test that run_agent stores the role in conversation history."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = _make_entity(mock_agent)
|
||||
|
||||
# Send as system role
|
||||
request = RunRequest(
|
||||
message="System message",
|
||||
role=Role.SYSTEM,
|
||||
correlation_id="corr-runreq-3",
|
||||
)
|
||||
|
||||
await entity.run(request)
|
||||
|
||||
# Check that system role was stored
|
||||
history = entity.state.data.conversation_history
|
||||
assert history[0].messages[0].role == "system"
|
||||
assert history[0].messages[0].text == "System message"
|
||||
|
||||
async def test_run_agent_with_response_format(self) -> None:
|
||||
"""Test run_agent with a JSON response format."""
|
||||
mock_agent = Mock()
|
||||
# Return JSON response
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response('{"answer": 42}'))
|
||||
|
||||
entity = _make_entity(mock_agent)
|
||||
|
||||
request = RunRequest(
|
||||
message="What is the answer?",
|
||||
response_format=EntityStructuredResponse,
|
||||
correlation_id="corr-runreq-4",
|
||||
)
|
||||
|
||||
result = await entity.run(request)
|
||||
|
||||
assert isinstance(result, AgentResponse)
|
||||
assert result.text == '{"answer": 42}'
|
||||
assert result.value is None
|
||||
|
||||
async def test_run_agent_disable_tool_calls(self) -> None:
|
||||
"""Test run_agent with tool calls disabled."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = _make_entity(mock_agent)
|
||||
|
||||
request = RunRequest(message="Test", enable_tool_calls=False, correlation_id="corr-runreq-5")
|
||||
|
||||
result = await entity.run(request)
|
||||
|
||||
assert isinstance(result, AgentResponse)
|
||||
# Agent should have been called (tool disabling is framework-dependent)
|
||||
mock_agent.run.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
@@ -0,0 +1,571 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for DurableAgentExecutor implementations.
|
||||
|
||||
Focuses on critical behavioral flows for executor strategies.
|
||||
Run with: pytest tests/test_executors.py -v
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from agent_framework import AgentResponse, Role
|
||||
from durabletask.entities import EntityInstanceId
|
||||
from durabletask.task import Task
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework_durabletask import DurableAgentThread
|
||||
from agent_framework_durabletask._constants import DEFAULT_MAX_POLL_RETRIES, DEFAULT_POLL_INTERVAL_SECONDS
|
||||
from agent_framework_durabletask._executors import (
|
||||
ClientAgentExecutor,
|
||||
DurableAgentTask,
|
||||
OrchestrationAgentExecutor,
|
||||
)
|
||||
from agent_framework_durabletask._models import AgentSessionId, RunRequest
|
||||
|
||||
|
||||
# Fixtures
|
||||
@pytest.fixture
|
||||
def mock_client() -> Mock:
|
||||
"""Provide a mock client for ClientAgentExecutor tests."""
|
||||
client = Mock()
|
||||
client.signal_entity = Mock()
|
||||
client.get_entity = Mock(return_value=None)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_entity_task() -> Mock:
|
||||
"""Provide a mock entity task."""
|
||||
task = Mock(spec=Task)
|
||||
task.is_complete = False
|
||||
task.is_failed = False
|
||||
return task
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_orchestration_context(mock_entity_task: Mock) -> Mock:
|
||||
"""Provide a mock orchestration context with call_entity configured."""
|
||||
context = Mock()
|
||||
context.call_entity = Mock(return_value=mock_entity_task)
|
||||
return context
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_run_request() -> RunRequest:
|
||||
"""Provide a sample RunRequest for tests."""
|
||||
return RunRequest(message="test message", correlation_id="test-123")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_executor(mock_client: Mock) -> ClientAgentExecutor:
|
||||
"""Provide a ClientAgentExecutor with minimal polling for fast tests."""
|
||||
return ClientAgentExecutor(mock_client, max_poll_retries=1, poll_interval_seconds=0.01)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def orchestration_executor(mock_orchestration_context: Mock) -> OrchestrationAgentExecutor:
|
||||
"""Provide an OrchestrationAgentExecutor."""
|
||||
return OrchestrationAgentExecutor(mock_orchestration_context)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def successful_agent_response() -> dict[str, Any]:
|
||||
"""Provide a successful agent response dictionary."""
|
||||
return {
|
||||
"messages": [{"role": "assistant", "contents": [{"type": "text", "text": "Hello!"}]}],
|
||||
"created_at": "2025-12-30T10:00:00Z",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def configure_successful_entity_task(mock_entity_task: Mock) -> Any:
|
||||
"""Provide a helper to configure mock_entity_task with a successful response."""
|
||||
|
||||
def _configure(response: dict[str, Any]) -> Mock:
|
||||
mock_entity_task.is_failed = False
|
||||
mock_entity_task.is_complete = False
|
||||
mock_entity_task.get_result = Mock(return_value=response)
|
||||
return mock_entity_task
|
||||
|
||||
return _configure
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def configure_failed_entity_task(mock_entity_task: Mock) -> Any:
|
||||
"""Provide a helper to configure mock_entity_task with a failure."""
|
||||
|
||||
def _configure(exception: Exception) -> Mock:
|
||||
mock_entity_task.is_failed = True
|
||||
mock_entity_task.is_complete = True
|
||||
mock_entity_task.get_exception = Mock(return_value=exception)
|
||||
return mock_entity_task
|
||||
|
||||
return _configure
|
||||
|
||||
|
||||
class TestExecutorThreadCreation:
|
||||
"""Test that executors properly create DurableAgentThread with parameters."""
|
||||
|
||||
def test_client_executor_creates_durable_thread(self, mock_client: Mock) -> None:
|
||||
"""Verify ClientAgentExecutor creates DurableAgentThread instances."""
|
||||
executor = ClientAgentExecutor(mock_client)
|
||||
|
||||
thread = executor.get_new_thread("test_agent")
|
||||
|
||||
assert isinstance(thread, DurableAgentThread)
|
||||
|
||||
def test_client_executor_forwards_kwargs_to_thread(self, mock_client: Mock) -> None:
|
||||
"""Verify ClientAgentExecutor forwards kwargs to DurableAgentThread creation."""
|
||||
executor = ClientAgentExecutor(mock_client)
|
||||
|
||||
thread = executor.get_new_thread("test_agent", service_thread_id="client-123")
|
||||
|
||||
assert isinstance(thread, DurableAgentThread)
|
||||
assert thread.service_thread_id == "client-123"
|
||||
|
||||
def test_orchestration_executor_creates_durable_thread(
|
||||
self, orchestration_executor: OrchestrationAgentExecutor
|
||||
) -> None:
|
||||
"""Verify OrchestrationAgentExecutor creates DurableAgentThread instances."""
|
||||
thread = orchestration_executor.get_new_thread("test_agent")
|
||||
|
||||
assert isinstance(thread, DurableAgentThread)
|
||||
|
||||
def test_orchestration_executor_forwards_kwargs_to_thread(
|
||||
self, orchestration_executor: OrchestrationAgentExecutor
|
||||
) -> None:
|
||||
"""Verify OrchestrationAgentExecutor forwards kwargs to DurableAgentThread creation."""
|
||||
thread = orchestration_executor.get_new_thread("test_agent", service_thread_id="orch-456")
|
||||
|
||||
assert isinstance(thread, DurableAgentThread)
|
||||
assert thread.service_thread_id == "orch-456"
|
||||
|
||||
|
||||
class TestClientAgentExecutorRun:
|
||||
"""Test that ClientAgentExecutor.run_durable_agent works as implemented."""
|
||||
|
||||
def test_client_executor_run_returns_response(
|
||||
self, client_executor: ClientAgentExecutor, sample_run_request: RunRequest
|
||||
) -> None:
|
||||
"""Verify ClientAgentExecutor.run_durable_agent returns AgentResponse (synchronous)."""
|
||||
result = client_executor.run_durable_agent("test_agent", sample_run_request)
|
||||
|
||||
# Verify it returns an AgentResponse (synchronous, not a coroutine)
|
||||
assert isinstance(result, AgentResponse)
|
||||
assert result is not None
|
||||
|
||||
|
||||
class TestClientAgentExecutorPollingConfiguration:
|
||||
"""Test polling configuration parameters for ClientAgentExecutor."""
|
||||
|
||||
def test_executor_uses_default_polling_parameters(self, mock_client: Mock) -> None:
|
||||
"""Verify executor initializes with default polling parameters."""
|
||||
executor = ClientAgentExecutor(mock_client)
|
||||
|
||||
assert executor.max_poll_retries == DEFAULT_MAX_POLL_RETRIES
|
||||
assert executor.poll_interval_seconds == DEFAULT_POLL_INTERVAL_SECONDS
|
||||
|
||||
def test_executor_accepts_custom_polling_parameters(self, mock_client: Mock) -> None:
|
||||
"""Verify executor accepts and stores custom polling parameters."""
|
||||
executor = ClientAgentExecutor(mock_client, max_poll_retries=20, poll_interval_seconds=0.5)
|
||||
|
||||
assert executor.max_poll_retries == 20
|
||||
assert executor.poll_interval_seconds == 0.5
|
||||
|
||||
def test_executor_respects_custom_max_poll_retries(self, mock_client: Mock, sample_run_request: RunRequest) -> None:
|
||||
"""Verify executor respects custom max_poll_retries during polling."""
|
||||
# Create executor with only 2 retries
|
||||
executor = ClientAgentExecutor(mock_client, max_poll_retries=2, poll_interval_seconds=0.01)
|
||||
|
||||
# Run the agent
|
||||
result = executor.run_durable_agent("test_agent", sample_run_request)
|
||||
|
||||
# Verify it returns AgentResponse (should timeout after 2 attempts)
|
||||
assert isinstance(result, AgentResponse)
|
||||
|
||||
# Verify get_entity was called 2 times (max_poll_retries)
|
||||
assert mock_client.get_entity.call_count == 2
|
||||
|
||||
def test_executor_respects_custom_poll_interval(self, mock_client: Mock, sample_run_request: RunRequest) -> None:
|
||||
"""Verify executor respects custom poll_interval_seconds during polling."""
|
||||
# Create executor with very short interval
|
||||
executor = ClientAgentExecutor(mock_client, max_poll_retries=3, poll_interval_seconds=0.01)
|
||||
|
||||
# Measure time taken
|
||||
start = time.time()
|
||||
result = executor.run_durable_agent("test_agent", sample_run_request)
|
||||
elapsed = time.time() - start
|
||||
|
||||
# Should take roughly 3 * 0.01 = 0.03 seconds (plus overhead)
|
||||
# Be generous with timing to avoid flakiness
|
||||
assert elapsed < 0.2 # Should be quick with 0.01 interval
|
||||
assert isinstance(result, AgentResponse)
|
||||
|
||||
|
||||
class TestClientAgentExecutorFireAndForget:
|
||||
"""Test fire-and-forget mode (wait_for_response=False) for ClientAgentExecutor."""
|
||||
|
||||
def test_fire_and_forget_returns_immediately(self, mock_client: Mock) -> None:
|
||||
"""Verify wait_for_response=False returns immediately without polling."""
|
||||
executor = ClientAgentExecutor(mock_client, max_poll_retries=10, poll_interval_seconds=0.1)
|
||||
|
||||
# Create a request with wait_for_response=False
|
||||
request = RunRequest(message="test message", correlation_id="test-123", wait_for_response=False)
|
||||
|
||||
# Measure time taken
|
||||
start = time.time()
|
||||
result = executor.run_durable_agent("test_agent", request)
|
||||
elapsed = time.time() - start
|
||||
|
||||
# Should return immediately without polling (elapsed time should be very small)
|
||||
assert elapsed < 0.1 # Much faster than any polling would take
|
||||
|
||||
# Should return an AgentResponse
|
||||
assert isinstance(result, AgentResponse)
|
||||
|
||||
# Should have signaled the entity but not polled
|
||||
assert mock_client.signal_entity.call_count == 1
|
||||
assert mock_client.get_entity.call_count == 0 # No polling occurred
|
||||
|
||||
def test_fire_and_forget_returns_empty_response(self, mock_client: Mock) -> None:
|
||||
"""Verify wait_for_response=False returns an acceptance message with correlation ID."""
|
||||
executor = ClientAgentExecutor(mock_client)
|
||||
|
||||
request = RunRequest(message="test message", correlation_id="test-456", wait_for_response=False)
|
||||
|
||||
result = executor.run_durable_agent("test_agent", request)
|
||||
|
||||
# Verify it contains an acceptance message
|
||||
assert isinstance(result, AgentResponse)
|
||||
assert len(result.messages) == 1
|
||||
assert result.messages[0].role == Role.SYSTEM
|
||||
# Check message contains key information
|
||||
message_text = result.messages[0].text
|
||||
assert "accepted" in message_text.lower()
|
||||
assert "test-456" in message_text # Contains correlation ID
|
||||
assert "background" in message_text.lower()
|
||||
|
||||
|
||||
class TestOrchestrationAgentExecutorFireAndForget:
|
||||
"""Test fire-and-forget mode for OrchestrationAgentExecutor."""
|
||||
|
||||
def test_orchestration_fire_and_forget_calls_signal_entity(self, mock_orchestration_context: Mock) -> None:
|
||||
"""Verify wait_for_response=False calls signal_entity instead of call_entity."""
|
||||
executor = OrchestrationAgentExecutor(mock_orchestration_context)
|
||||
mock_orchestration_context.signal_entity = Mock()
|
||||
|
||||
request = RunRequest(message="test", correlation_id="test-123", wait_for_response=False)
|
||||
|
||||
result = executor.run_durable_agent("test_agent", request)
|
||||
|
||||
# Verify signal_entity was called and call_entity was not
|
||||
assert mock_orchestration_context.signal_entity.call_count == 1
|
||||
assert mock_orchestration_context.call_entity.call_count == 0
|
||||
|
||||
# Should still return a DurableAgentTask
|
||||
assert isinstance(result, DurableAgentTask)
|
||||
|
||||
def test_orchestration_fire_and_forget_returns_completed_task(self, mock_orchestration_context: Mock) -> None:
|
||||
"""Verify wait_for_response=False returns pre-completed DurableAgentTask."""
|
||||
executor = OrchestrationAgentExecutor(mock_orchestration_context)
|
||||
mock_orchestration_context.signal_entity = Mock()
|
||||
|
||||
request = RunRequest(message="test", correlation_id="test-456", wait_for_response=False)
|
||||
|
||||
result = executor.run_durable_agent("test_agent", request)
|
||||
|
||||
# Task should be immediately complete
|
||||
assert isinstance(result, DurableAgentTask)
|
||||
assert result.is_complete
|
||||
|
||||
def test_orchestration_fire_and_forget_returns_acceptance_response(self, mock_orchestration_context: Mock) -> None:
|
||||
"""Verify wait_for_response=False returns acceptance response."""
|
||||
executor = OrchestrationAgentExecutor(mock_orchestration_context)
|
||||
mock_orchestration_context.signal_entity = Mock()
|
||||
|
||||
request = RunRequest(message="test", correlation_id="test-789", wait_for_response=False)
|
||||
|
||||
result = executor.run_durable_agent("test_agent", request)
|
||||
|
||||
# Get the result
|
||||
response = result.get_result()
|
||||
assert isinstance(response, AgentResponse)
|
||||
assert len(response.messages) == 1
|
||||
assert response.messages[0].role == Role.SYSTEM
|
||||
assert "test-789" in response.messages[0].text
|
||||
|
||||
def test_orchestration_blocking_mode_calls_call_entity(self, mock_orchestration_context: Mock) -> None:
|
||||
"""Verify wait_for_response=True uses call_entity as before."""
|
||||
executor = OrchestrationAgentExecutor(mock_orchestration_context)
|
||||
mock_orchestration_context.signal_entity = Mock()
|
||||
|
||||
request = RunRequest(message="test", correlation_id="test-abc", wait_for_response=True)
|
||||
|
||||
result = executor.run_durable_agent("test_agent", request)
|
||||
|
||||
# Verify call_entity was called and signal_entity was not
|
||||
assert mock_orchestration_context.call_entity.call_count == 1
|
||||
assert mock_orchestration_context.signal_entity.call_count == 0
|
||||
|
||||
# Should return a DurableAgentTask
|
||||
assert isinstance(result, DurableAgentTask)
|
||||
|
||||
|
||||
class TestOrchestrationAgentExecutorRun:
|
||||
"""Test OrchestrationAgentExecutor.run_durable_agent implementation."""
|
||||
|
||||
def test_orchestration_executor_run_returns_durable_agent_task(
|
||||
self, orchestration_executor: OrchestrationAgentExecutor, sample_run_request: RunRequest
|
||||
) -> None:
|
||||
"""Verify OrchestrationAgentExecutor.run_durable_agent returns DurableAgentTask."""
|
||||
result = orchestration_executor.run_durable_agent("test_agent", sample_run_request)
|
||||
|
||||
assert isinstance(result, DurableAgentTask)
|
||||
|
||||
def test_orchestration_executor_calls_entity_with_correct_parameters(
|
||||
self,
|
||||
mock_orchestration_context: Mock,
|
||||
orchestration_executor: OrchestrationAgentExecutor,
|
||||
sample_run_request: RunRequest,
|
||||
) -> None:
|
||||
"""Verify call_entity is invoked with correct entity ID and request."""
|
||||
orchestration_executor.run_durable_agent("test_agent", sample_run_request)
|
||||
|
||||
# Verify call_entity was called once
|
||||
assert mock_orchestration_context.call_entity.call_count == 1
|
||||
|
||||
# Get the call arguments
|
||||
call_args = mock_orchestration_context.call_entity.call_args
|
||||
entity_id_arg = call_args[0][0]
|
||||
operation_arg = call_args[0][1]
|
||||
request_dict_arg = call_args[0][2]
|
||||
|
||||
# Verify entity ID
|
||||
assert isinstance(entity_id_arg, EntityInstanceId)
|
||||
assert entity_id_arg.entity == "dafx-test_agent"
|
||||
|
||||
# Verify operation name
|
||||
assert operation_arg == "run"
|
||||
|
||||
# Verify request dict
|
||||
assert request_dict_arg == sample_run_request.to_dict()
|
||||
|
||||
def test_orchestration_executor_uses_thread_session_id(
|
||||
self,
|
||||
mock_orchestration_context: Mock,
|
||||
orchestration_executor: OrchestrationAgentExecutor,
|
||||
sample_run_request: RunRequest,
|
||||
) -> None:
|
||||
"""Verify executor uses thread's session ID when provided."""
|
||||
# Create thread with specific session ID
|
||||
session_id = AgentSessionId(name="test_agent", key="specific-key-123")
|
||||
thread = DurableAgentThread.from_session_id(session_id)
|
||||
|
||||
result = orchestration_executor.run_durable_agent("test_agent", sample_run_request, thread=thread)
|
||||
|
||||
# Verify call_entity was called with the specific key
|
||||
call_args = mock_orchestration_context.call_entity.call_args
|
||||
entity_id_arg = call_args[0][0]
|
||||
|
||||
assert entity_id_arg.key == "specific-key-123"
|
||||
assert isinstance(result, DurableAgentTask)
|
||||
|
||||
|
||||
class TestDurableAgentTask:
|
||||
"""Test DurableAgentTask completion and response transformation."""
|
||||
|
||||
def test_durable_agent_task_transforms_successful_result(
|
||||
self, configure_successful_entity_task: Any, successful_agent_response: dict[str, Any]
|
||||
) -> None:
|
||||
"""Verify DurableAgentTask converts successful entity result to AgentResponse."""
|
||||
mock_entity_task = configure_successful_entity_task(successful_agent_response)
|
||||
|
||||
task = DurableAgentTask(entity_task=mock_entity_task, response_format=None, correlation_id="test-123")
|
||||
|
||||
# Simulate child task completion
|
||||
task.on_child_completed(mock_entity_task)
|
||||
|
||||
assert task.is_complete
|
||||
result = task.get_result()
|
||||
assert isinstance(result, AgentResponse)
|
||||
assert len(result.messages) == 1
|
||||
assert result.messages[0].role == Role.ASSISTANT
|
||||
|
||||
def test_durable_agent_task_propagates_failure(self, configure_failed_entity_task: Any) -> None:
|
||||
"""Verify DurableAgentTask propagates task failures."""
|
||||
mock_entity_task = configure_failed_entity_task(ValueError("Entity error"))
|
||||
|
||||
task = DurableAgentTask(entity_task=mock_entity_task, response_format=None, correlation_id="test-123")
|
||||
|
||||
# Simulate child task completion with failure
|
||||
task.on_child_completed(mock_entity_task)
|
||||
|
||||
assert task.is_complete
|
||||
assert task.is_failed
|
||||
# The exception is wrapped in TaskFailedError by the durabletask library
|
||||
exception = task.get_exception()
|
||||
assert exception is not None
|
||||
|
||||
def test_durable_agent_task_validates_response_format(self, configure_successful_entity_task: Any) -> None:
|
||||
"""Verify DurableAgentTask validates response format when provided."""
|
||||
response = {
|
||||
"messages": [{"role": "assistant", "contents": [{"type": "text", "text": '{"answer": "42"}'}]}],
|
||||
"created_at": "2025-12-30T10:00:00Z",
|
||||
}
|
||||
mock_entity_task = configure_successful_entity_task(response)
|
||||
|
||||
class TestResponse(BaseModel):
|
||||
answer: str
|
||||
|
||||
task = DurableAgentTask(entity_task=mock_entity_task, response_format=TestResponse, correlation_id="test-123")
|
||||
|
||||
# Simulate child task completion
|
||||
task.on_child_completed(mock_entity_task)
|
||||
|
||||
assert task.is_complete
|
||||
result = task.get_result()
|
||||
assert isinstance(result, AgentResponse)
|
||||
|
||||
def test_durable_agent_task_ignores_duplicate_completion(
|
||||
self, configure_successful_entity_task: Any, successful_agent_response: dict[str, Any]
|
||||
) -> None:
|
||||
"""Verify DurableAgentTask ignores duplicate completion calls."""
|
||||
mock_entity_task = configure_successful_entity_task(successful_agent_response)
|
||||
|
||||
task = DurableAgentTask(entity_task=mock_entity_task, response_format=None, correlation_id="test-123")
|
||||
|
||||
# Simulate child task completion twice
|
||||
task.on_child_completed(mock_entity_task)
|
||||
first_result = task.get_result()
|
||||
|
||||
task.on_child_completed(mock_entity_task)
|
||||
second_result = task.get_result()
|
||||
|
||||
# Should be the same result, get_result should only be called once
|
||||
assert first_result is second_result
|
||||
assert mock_entity_task.get_result.call_count == 1
|
||||
|
||||
def test_durable_agent_task_fails_on_malformed_response(self, configure_successful_entity_task: Any) -> None:
|
||||
"""Verify DurableAgentTask fails when entity returns malformed response data."""
|
||||
# Use data that will cause AgentResponse.from_dict to fail
|
||||
# Using a list instead of dict, or other invalid structure
|
||||
mock_entity_task = configure_successful_entity_task("invalid string response")
|
||||
|
||||
task = DurableAgentTask(entity_task=mock_entity_task, response_format=None, correlation_id="test-123")
|
||||
|
||||
# Simulate child task completion with malformed data
|
||||
task.on_child_completed(mock_entity_task)
|
||||
|
||||
assert task.is_complete
|
||||
assert task.is_failed
|
||||
|
||||
def test_durable_agent_task_fails_on_invalid_response_format(self, configure_successful_entity_task: Any) -> None:
|
||||
"""Verify DurableAgentTask fails when response doesn't match required format."""
|
||||
response = {
|
||||
"messages": [{"role": "assistant", "contents": [{"type": "text", "text": '{"wrong": "field"}'}]}],
|
||||
"created_at": "2025-12-30T10:00:00Z",
|
||||
}
|
||||
mock_entity_task = configure_successful_entity_task(response)
|
||||
|
||||
class StrictResponse(BaseModel):
|
||||
required_field: str
|
||||
|
||||
task = DurableAgentTask(entity_task=mock_entity_task, response_format=StrictResponse, correlation_id="test-123")
|
||||
|
||||
# Simulate child task completion with wrong format
|
||||
task.on_child_completed(mock_entity_task)
|
||||
|
||||
assert task.is_complete
|
||||
assert task.is_failed
|
||||
|
||||
def test_durable_agent_task_handles_empty_response(self, configure_successful_entity_task: Any) -> None:
|
||||
"""Verify DurableAgentTask handles response with empty messages list."""
|
||||
response: dict[str, str | list[Any]] = {
|
||||
"messages": [],
|
||||
"created_at": "2025-12-30T10:00:00Z",
|
||||
}
|
||||
mock_entity_task = configure_successful_entity_task(response)
|
||||
|
||||
task = DurableAgentTask(entity_task=mock_entity_task, response_format=None, correlation_id="test-123")
|
||||
|
||||
# Simulate child task completion
|
||||
task.on_child_completed(mock_entity_task)
|
||||
|
||||
assert task.is_complete
|
||||
result = task.get_result()
|
||||
assert isinstance(result, AgentResponse)
|
||||
assert len(result.messages) == 0
|
||||
|
||||
def test_durable_agent_task_handles_multiple_messages(self, configure_successful_entity_task: Any) -> None:
|
||||
"""Verify DurableAgentTask correctly processes response with multiple messages."""
|
||||
response = {
|
||||
"messages": [
|
||||
{"role": "assistant", "contents": [{"type": "text", "text": "First message"}]},
|
||||
{"role": "assistant", "contents": [{"type": "text", "text": "Second message"}]},
|
||||
],
|
||||
"created_at": "2025-12-30T10:00:00Z",
|
||||
}
|
||||
mock_entity_task = configure_successful_entity_task(response)
|
||||
|
||||
task = DurableAgentTask(entity_task=mock_entity_task, response_format=None, correlation_id="test-123")
|
||||
|
||||
# Simulate child task completion
|
||||
task.on_child_completed(mock_entity_task)
|
||||
|
||||
assert task.is_complete
|
||||
result = task.get_result()
|
||||
assert isinstance(result, AgentResponse)
|
||||
assert len(result.messages) == 2
|
||||
assert result.messages[0].role == Role.ASSISTANT
|
||||
assert result.messages[1].role == Role.ASSISTANT
|
||||
|
||||
def test_durable_agent_task_is_not_complete_initially(self, mock_entity_task: Mock) -> None:
|
||||
"""Verify DurableAgentTask is not complete when first created."""
|
||||
task = DurableAgentTask(entity_task=mock_entity_task, response_format=None, correlation_id="test-123")
|
||||
|
||||
assert not task.is_complete
|
||||
assert not task.is_failed
|
||||
|
||||
def test_durable_agent_task_completes_with_complex_response_format(
|
||||
self, configure_successful_entity_task: Any
|
||||
) -> None:
|
||||
"""Verify DurableAgentTask validates complex nested response formats correctly."""
|
||||
response = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"contents": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": '{"name": "test", "count": 42, "items": ["a", "b", "c"]}',
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
"created_at": "2025-12-30T10:00:00Z",
|
||||
}
|
||||
mock_entity_task = configure_successful_entity_task(response)
|
||||
|
||||
class ComplexResponse(BaseModel):
|
||||
name: str
|
||||
count: int
|
||||
items: list[str]
|
||||
|
||||
task = DurableAgentTask(
|
||||
entity_task=mock_entity_task, response_format=ComplexResponse, correlation_id="test-123"
|
||||
)
|
||||
|
||||
# Simulate child task completion
|
||||
task.on_child_completed(mock_entity_task)
|
||||
|
||||
assert task.is_complete
|
||||
assert not task.is_failed
|
||||
result = task.get_result()
|
||||
assert isinstance(result, AgentResponse)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
@@ -0,0 +1,310 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for data models (RunRequest)."""
|
||||
|
||||
import pytest
|
||||
from agent_framework import Role
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework_durabletask._models import RunRequest
|
||||
|
||||
|
||||
class ModuleStructuredResponse(BaseModel):
|
||||
value: int
|
||||
|
||||
|
||||
class TestRunRequest:
|
||||
"""Test suite for RunRequest."""
|
||||
|
||||
def test_init_with_defaults(self) -> None:
|
||||
"""Test RunRequest initialization with defaults."""
|
||||
request = RunRequest(message="Hello", correlation_id="corr-001")
|
||||
|
||||
assert request.message == "Hello"
|
||||
assert request.correlation_id == "corr-001"
|
||||
assert request.role == Role.USER
|
||||
assert request.response_format is None
|
||||
assert request.enable_tool_calls is True
|
||||
assert request.wait_for_response is True
|
||||
|
||||
def test_init_with_all_fields(self) -> None:
|
||||
"""Test RunRequest initialization with all fields."""
|
||||
schema = ModuleStructuredResponse
|
||||
request = RunRequest(
|
||||
message="Hello",
|
||||
correlation_id="corr-002",
|
||||
role=Role.SYSTEM,
|
||||
response_format=schema,
|
||||
enable_tool_calls=False,
|
||||
wait_for_response=False,
|
||||
)
|
||||
|
||||
assert request.message == "Hello"
|
||||
assert request.correlation_id == "corr-002"
|
||||
assert request.role == Role.SYSTEM
|
||||
assert request.response_format is schema
|
||||
assert request.enable_tool_calls is False
|
||||
assert request.wait_for_response is False
|
||||
|
||||
def test_init_coerces_string_role(self) -> None:
|
||||
"""Ensure string role values are coerced into Role instances."""
|
||||
request = RunRequest(message="Hello", correlation_id="corr-003", role="system") # type: ignore[arg-type]
|
||||
|
||||
assert request.role == Role.SYSTEM
|
||||
|
||||
def test_to_dict_with_defaults(self) -> None:
|
||||
"""Test to_dict with default values."""
|
||||
request = RunRequest(message="Test message", correlation_id="corr-004")
|
||||
data = request.to_dict()
|
||||
|
||||
assert data["message"] == "Test message"
|
||||
assert data["enable_tool_calls"] is True
|
||||
assert data["wait_for_response"] is True
|
||||
assert data["role"] == "user"
|
||||
assert data["correlationId"] == "corr-004"
|
||||
assert "response_format" not in data or data["response_format"] is None
|
||||
assert "thread_id" not in data
|
||||
|
||||
def test_to_dict_with_all_fields(self) -> None:
|
||||
"""Test to_dict with all fields."""
|
||||
schema = ModuleStructuredResponse
|
||||
request = RunRequest(
|
||||
message="Hello",
|
||||
correlation_id="corr-005",
|
||||
role=Role.ASSISTANT,
|
||||
response_format=schema,
|
||||
enable_tool_calls=False,
|
||||
wait_for_response=False,
|
||||
)
|
||||
data = request.to_dict()
|
||||
|
||||
assert data["message"] == "Hello"
|
||||
assert data["correlationId"] == "corr-005"
|
||||
assert data["role"] == "assistant"
|
||||
assert data["response_format"]["__response_schema_type__"] == "pydantic_model"
|
||||
assert data["response_format"]["module"] == schema.__module__
|
||||
assert data["response_format"]["qualname"] == schema.__qualname__
|
||||
assert data["enable_tool_calls"] is False
|
||||
assert data["wait_for_response"] is False
|
||||
assert "thread_id" not in data
|
||||
|
||||
def test_from_dict_with_defaults(self) -> None:
|
||||
"""Test from_dict with minimal data."""
|
||||
data = {"message": "Hello", "correlationId": "corr-006"}
|
||||
request = RunRequest.from_dict(data)
|
||||
|
||||
assert request.message == "Hello"
|
||||
assert request.correlation_id == "corr-006"
|
||||
assert request.role == Role.USER
|
||||
assert request.enable_tool_calls is True
|
||||
assert request.wait_for_response is True
|
||||
|
||||
def test_from_dict_ignores_thread_id_field(self) -> None:
|
||||
"""Ensure legacy thread_id input does not break RunRequest parsing."""
|
||||
request = RunRequest.from_dict({"message": "Hello", "correlationId": "corr-007", "thread_id": "ignored"})
|
||||
|
||||
assert request.message == "Hello"
|
||||
|
||||
def test_from_dict_with_all_fields(self) -> None:
|
||||
"""Test from_dict with all fields."""
|
||||
data = {
|
||||
"message": "Test",
|
||||
"correlationId": "corr-008",
|
||||
"role": "system",
|
||||
"response_format": {
|
||||
"__response_schema_type__": "pydantic_model",
|
||||
"module": ModuleStructuredResponse.__module__,
|
||||
"qualname": ModuleStructuredResponse.__qualname__,
|
||||
},
|
||||
"enable_tool_calls": False,
|
||||
}
|
||||
request = RunRequest.from_dict(data)
|
||||
|
||||
assert request.message == "Test"
|
||||
assert request.correlation_id == "corr-008"
|
||||
assert request.role == Role.SYSTEM
|
||||
assert request.response_format is ModuleStructuredResponse
|
||||
assert request.enable_tool_calls is False
|
||||
|
||||
def test_from_dict_unknown_role_preserves_value(self) -> None:
|
||||
"""Test from_dict keeps custom roles intact."""
|
||||
data = {"message": "Test", "correlationId": "corr-009", "role": "reviewer"}
|
||||
request = RunRequest.from_dict(data)
|
||||
|
||||
assert request.role.value == "reviewer"
|
||||
assert request.role != Role.USER
|
||||
|
||||
def test_from_dict_empty_message(self) -> None:
|
||||
"""Test from_dict with empty message."""
|
||||
request = RunRequest.from_dict({"correlationId": "corr-010"})
|
||||
|
||||
assert request.message == ""
|
||||
assert request.correlation_id == "corr-010"
|
||||
assert request.role == Role.USER
|
||||
|
||||
def test_from_dict_missing_correlation_id_raises(self) -> None:
|
||||
"""Test from_dict raises when correlationId is missing."""
|
||||
with pytest.raises(ValueError, match="correlationId is required"):
|
||||
RunRequest.from_dict({"message": "Test"})
|
||||
|
||||
def test_round_trip_dict_conversion(self) -> None:
|
||||
"""Test round-trip to_dict and from_dict."""
|
||||
original = RunRequest(
|
||||
message="Test message",
|
||||
correlation_id="corr-011",
|
||||
role=Role.SYSTEM,
|
||||
response_format=ModuleStructuredResponse,
|
||||
enable_tool_calls=False,
|
||||
)
|
||||
|
||||
data = original.to_dict()
|
||||
restored = RunRequest.from_dict(data)
|
||||
|
||||
assert restored.message == original.message
|
||||
assert restored.correlation_id == original.correlation_id
|
||||
assert restored.role == original.role
|
||||
assert restored.response_format is ModuleStructuredResponse
|
||||
assert restored.enable_tool_calls == original.enable_tool_calls
|
||||
|
||||
def test_round_trip_with_pydantic_response_format(self) -> None:
|
||||
"""Ensure Pydantic response formats serialize and deserialize properly."""
|
||||
original = RunRequest(
|
||||
message="Structured",
|
||||
correlation_id="corr-012",
|
||||
response_format=ModuleStructuredResponse,
|
||||
)
|
||||
|
||||
data = original.to_dict()
|
||||
|
||||
assert data["response_format"]["__response_schema_type__"] == "pydantic_model"
|
||||
assert data["response_format"]["module"] == ModuleStructuredResponse.__module__
|
||||
assert data["response_format"]["qualname"] == ModuleStructuredResponse.__qualname__
|
||||
|
||||
restored = RunRequest.from_dict(data)
|
||||
assert restored.response_format is ModuleStructuredResponse
|
||||
|
||||
def test_round_trip_with_options(self) -> None:
|
||||
"""Ensure options are preserved and response_format is deserialized."""
|
||||
original = RunRequest(
|
||||
message="Test",
|
||||
correlation_id="corr-opts-1",
|
||||
response_format=ModuleStructuredResponse,
|
||||
enable_tool_calls=False,
|
||||
options={
|
||||
"response_format": ModuleStructuredResponse,
|
||||
"enable_tool_calls": False,
|
||||
"custom": "value",
|
||||
},
|
||||
)
|
||||
|
||||
data = original.to_dict()
|
||||
assert data["options"]["custom"] == "value"
|
||||
|
||||
restored = RunRequest.from_dict(data)
|
||||
assert restored.options is not None
|
||||
assert restored.options["custom"] == "value"
|
||||
assert restored.options["response_format"] is ModuleStructuredResponse
|
||||
|
||||
def test_init_with_correlationId(self) -> None:
|
||||
"""Test RunRequest initialization with correlationId."""
|
||||
request = RunRequest(message="Test message", correlation_id="corr-123")
|
||||
|
||||
assert request.message == "Test message"
|
||||
assert request.correlation_id == "corr-123"
|
||||
|
||||
def test_to_dict_with_correlationId(self) -> None:
|
||||
"""Test to_dict includes correlationId."""
|
||||
request = RunRequest(message="Test", correlation_id="corr-456")
|
||||
data = request.to_dict()
|
||||
|
||||
assert data["message"] == "Test"
|
||||
assert data["correlationId"] == "corr-456"
|
||||
|
||||
def test_from_dict_with_correlationId(self) -> None:
|
||||
"""Test from_dict with correlationId."""
|
||||
data = {"message": "Test", "correlationId": "corr-789"}
|
||||
request = RunRequest.from_dict(data)
|
||||
|
||||
assert request.message == "Test"
|
||||
assert request.correlation_id == "corr-789"
|
||||
|
||||
def test_round_trip_with_correlationId(self) -> None:
|
||||
"""Test round-trip to_dict and from_dict with correlationId."""
|
||||
original = RunRequest(
|
||||
message="Test message",
|
||||
role=Role.SYSTEM,
|
||||
correlation_id="corr-124",
|
||||
)
|
||||
|
||||
data = original.to_dict()
|
||||
restored = RunRequest.from_dict(data)
|
||||
|
||||
assert restored.message == original.message
|
||||
assert restored.role == original.role
|
||||
assert restored.correlation_id == original.correlation_id
|
||||
|
||||
def test_init_with_orchestration_id(self) -> None:
|
||||
"""Test RunRequest initialization with orchestration_id."""
|
||||
request = RunRequest(
|
||||
message="Test message",
|
||||
correlation_id="corr-125",
|
||||
orchestration_id="orch-123",
|
||||
)
|
||||
|
||||
assert request.message == "Test message"
|
||||
assert request.orchestration_id == "orch-123"
|
||||
|
||||
def test_to_dict_with_orchestration_id(self) -> None:
|
||||
"""Test to_dict includes orchestrationId."""
|
||||
request = RunRequest(
|
||||
message="Test",
|
||||
correlation_id="corr-126",
|
||||
orchestration_id="orch-456",
|
||||
)
|
||||
data = request.to_dict()
|
||||
|
||||
assert data["message"] == "Test"
|
||||
assert data["orchestrationId"] == "orch-456"
|
||||
|
||||
def test_to_dict_excludes_orchestration_id_when_none(self) -> None:
|
||||
"""Test to_dict excludes orchestrationId when not set."""
|
||||
request = RunRequest(
|
||||
message="Test",
|
||||
correlation_id="corr-127",
|
||||
)
|
||||
data = request.to_dict()
|
||||
|
||||
assert "orchestrationId" not in data
|
||||
|
||||
def test_from_dict_with_orchestration_id(self) -> None:
|
||||
"""Test from_dict with orchestrationId."""
|
||||
data = {
|
||||
"message": "Test",
|
||||
"correlationId": "corr-128",
|
||||
"orchestrationId": "orch-789",
|
||||
}
|
||||
request = RunRequest.from_dict(data)
|
||||
|
||||
assert request.message == "Test"
|
||||
assert request.orchestration_id == "orch-789"
|
||||
|
||||
def test_round_trip_with_orchestration_id(self) -> None:
|
||||
"""Test round-trip to_dict and from_dict with orchestration_id."""
|
||||
original = RunRequest(
|
||||
message="Test message",
|
||||
role=Role.SYSTEM,
|
||||
correlation_id="corr-129",
|
||||
orchestration_id="orch-123",
|
||||
)
|
||||
|
||||
data = original.to_dict()
|
||||
restored = RunRequest.from_dict(data)
|
||||
|
||||
assert restored.message == original.message
|
||||
assert restored.role == original.role
|
||||
assert restored.correlation_id == original.correlation_id
|
||||
assert restored.orchestration_id == original.orchestration_id
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
@@ -0,0 +1,98 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for DurableAIAgentOrchestrationContext.
|
||||
|
||||
Focuses on critical orchestration workflows: agent retrieval and integration.
|
||||
Run with: pytest tests/test_orchestration_context.py -v
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from agent_framework import AgentProtocol
|
||||
|
||||
from agent_framework_durabletask import DurableAgentThread
|
||||
from agent_framework_durabletask._orchestration_context import DurableAIAgentOrchestrationContext
|
||||
from agent_framework_durabletask._shim import DurableAIAgent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_orchestration_context() -> Mock:
|
||||
"""Create a mock OrchestrationContext for testing."""
|
||||
return Mock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent_context(mock_orchestration_context: Mock) -> DurableAIAgentOrchestrationContext:
|
||||
"""Create a DurableAIAgentOrchestrationContext with mock context."""
|
||||
return DurableAIAgentOrchestrationContext(mock_orchestration_context)
|
||||
|
||||
|
||||
class TestDurableAIAgentOrchestrationContextGetAgent:
|
||||
"""Test core workflow: retrieving agents from orchestration context."""
|
||||
|
||||
def test_get_agent_returns_durable_agent_shim(self, agent_context: DurableAIAgentOrchestrationContext) -> None:
|
||||
"""Verify get_agent returns a DurableAIAgent instance."""
|
||||
agent = agent_context.get_agent("assistant")
|
||||
|
||||
assert isinstance(agent, DurableAIAgent)
|
||||
assert isinstance(agent, AgentProtocol)
|
||||
|
||||
def test_get_agent_shim_has_correct_name(self, agent_context: DurableAIAgentOrchestrationContext) -> None:
|
||||
"""Verify retrieved agent has the correct name."""
|
||||
agent = agent_context.get_agent("my_agent")
|
||||
|
||||
assert agent.name == "my_agent"
|
||||
|
||||
def test_get_agent_multiple_times_returns_new_instances(
|
||||
self, agent_context: DurableAIAgentOrchestrationContext
|
||||
) -> None:
|
||||
"""Verify multiple get_agent calls return independent instances."""
|
||||
agent1 = agent_context.get_agent("assistant")
|
||||
agent2 = agent_context.get_agent("assistant")
|
||||
|
||||
assert agent1 is not agent2 # Different object instances
|
||||
|
||||
def test_get_agent_different_agents(self, agent_context: DurableAIAgentOrchestrationContext) -> None:
|
||||
"""Verify context can retrieve multiple different agents."""
|
||||
agent1 = agent_context.get_agent("agent1")
|
||||
agent2 = agent_context.get_agent("agent2")
|
||||
|
||||
assert agent1.name == "agent1"
|
||||
assert agent2.name == "agent2"
|
||||
|
||||
|
||||
class TestDurableAIAgentOrchestrationContextIntegration:
|
||||
"""Test integration scenarios between orchestration context and agent shim."""
|
||||
|
||||
def test_orchestration_agent_has_working_run_method(
|
||||
self, agent_context: DurableAIAgentOrchestrationContext
|
||||
) -> None:
|
||||
"""Verify agent from context has callable run method (even if not yet implemented)."""
|
||||
agent = agent_context.get_agent("assistant")
|
||||
|
||||
assert hasattr(agent, "run")
|
||||
assert callable(agent.run)
|
||||
|
||||
def test_orchestration_agent_can_create_threads(self, agent_context: DurableAIAgentOrchestrationContext) -> None:
|
||||
"""Verify agent from context can create DurableAgentThread instances."""
|
||||
agent = agent_context.get_agent("assistant")
|
||||
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
assert isinstance(thread, DurableAgentThread)
|
||||
|
||||
def test_orchestration_agent_thread_with_parameters(
|
||||
self, agent_context: DurableAIAgentOrchestrationContext
|
||||
) -> None:
|
||||
"""Verify agent can create threads with custom parameters."""
|
||||
agent = agent_context.get_agent("assistant")
|
||||
|
||||
thread = agent.get_new_thread(service_thread_id="orch-session-456")
|
||||
|
||||
assert isinstance(thread, DurableAgentThread)
|
||||
assert thread.service_thread_id == "orch-session-456"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
@@ -0,0 +1,213 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for DurableAIAgent shim and DurableAgentProvider.
|
||||
|
||||
Focuses on critical message normalization, delegation, and protocol compliance.
|
||||
Run with: pytest tests/test_shim.py -v
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from agent_framework import AgentProtocol, ChatMessage
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework_durabletask import DurableAgentThread
|
||||
from agent_framework_durabletask._executors import DurableAgentExecutor
|
||||
from agent_framework_durabletask._models import RunRequest
|
||||
from agent_framework_durabletask._shim import DurableAgentProvider, DurableAIAgent
|
||||
|
||||
|
||||
class ResponseFormatModel(BaseModel):
|
||||
"""Test Pydantic model for response format testing."""
|
||||
|
||||
result: str
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_executor() -> Mock:
|
||||
"""Create a mock executor for testing."""
|
||||
mock = Mock(spec=DurableAgentExecutor)
|
||||
mock.run_durable_agent = Mock(return_value=None)
|
||||
mock.get_new_thread = Mock(return_value=DurableAgentThread())
|
||||
|
||||
# Mock get_run_request to create actual RunRequest objects
|
||||
def create_run_request(
|
||||
message: str,
|
||||
options: dict[str, Any] | None = None,
|
||||
) -> RunRequest:
|
||||
import uuid
|
||||
|
||||
opts = dict(options) if options else {}
|
||||
response_format = opts.pop("response_format", None)
|
||||
enable_tool_calls = opts.pop("enable_tool_calls", True)
|
||||
wait_for_response = opts.pop("wait_for_response", True)
|
||||
return RunRequest(
|
||||
message=message,
|
||||
correlation_id=str(uuid.uuid4()),
|
||||
response_format=response_format,
|
||||
enable_tool_calls=enable_tool_calls,
|
||||
wait_for_response=wait_for_response,
|
||||
options=opts,
|
||||
)
|
||||
|
||||
mock.get_run_request = Mock(side_effect=create_run_request)
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_agent(mock_executor: Mock) -> DurableAIAgent[Any]:
|
||||
"""Create a test agent with mock executor."""
|
||||
return DurableAIAgent(mock_executor, "test_agent")
|
||||
|
||||
|
||||
class TestDurableAIAgentMessageNormalization:
|
||||
"""Test that DurableAIAgent properly normalizes various message input types."""
|
||||
|
||||
def test_run_accepts_string_message(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None:
|
||||
"""Verify run accepts and normalizes string messages."""
|
||||
test_agent.run("Hello, world!")
|
||||
|
||||
mock_executor.run_durable_agent.assert_called_once()
|
||||
# Verify agent_name and run_request were passed correctly as kwargs
|
||||
_, kwargs = mock_executor.run_durable_agent.call_args
|
||||
assert kwargs["agent_name"] == "test_agent"
|
||||
assert kwargs["run_request"].message == "Hello, world!"
|
||||
|
||||
def test_run_accepts_chat_message(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None:
|
||||
"""Verify run accepts and normalizes ChatMessage objects."""
|
||||
chat_msg = ChatMessage(role="user", text="Test message")
|
||||
test_agent.run(chat_msg)
|
||||
|
||||
mock_executor.run_durable_agent.assert_called_once()
|
||||
_, kwargs = mock_executor.run_durable_agent.call_args
|
||||
assert kwargs["run_request"].message == "Test message"
|
||||
|
||||
def test_run_accepts_list_of_strings(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None:
|
||||
"""Verify run accepts and joins list of strings."""
|
||||
test_agent.run(["First message", "Second message"])
|
||||
|
||||
mock_executor.run_durable_agent.assert_called_once()
|
||||
_, kwargs = mock_executor.run_durable_agent.call_args
|
||||
assert kwargs["run_request"].message == "First message\nSecond message"
|
||||
|
||||
def test_run_accepts_list_of_chat_messages(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None:
|
||||
"""Verify run accepts and joins list of ChatMessage objects."""
|
||||
messages = [
|
||||
ChatMessage(role="user", text="Message 1"),
|
||||
ChatMessage(role="assistant", text="Message 2"),
|
||||
]
|
||||
test_agent.run(messages)
|
||||
|
||||
mock_executor.run_durable_agent.assert_called_once()
|
||||
_, kwargs = mock_executor.run_durable_agent.call_args
|
||||
assert kwargs["run_request"].message == "Message 1\nMessage 2"
|
||||
|
||||
def test_run_handles_none_message(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None:
|
||||
"""Verify run handles None message gracefully."""
|
||||
test_agent.run(None)
|
||||
|
||||
mock_executor.run_durable_agent.assert_called_once()
|
||||
_, kwargs = mock_executor.run_durable_agent.call_args
|
||||
assert kwargs["run_request"].message == ""
|
||||
|
||||
def test_run_handles_empty_list(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None:
|
||||
"""Verify run handles empty list gracefully."""
|
||||
test_agent.run([])
|
||||
|
||||
mock_executor.run_durable_agent.assert_called_once()
|
||||
_, kwargs = mock_executor.run_durable_agent.call_args
|
||||
assert kwargs["run_request"].message == ""
|
||||
|
||||
|
||||
class TestDurableAIAgentParameterFlow:
|
||||
"""Test that parameters flow correctly through the shim to executor."""
|
||||
|
||||
def test_run_forwards_thread_parameter(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None:
|
||||
"""Verify run forwards thread parameter to executor."""
|
||||
thread = DurableAgentThread(service_thread_id="test-thread")
|
||||
test_agent.run("message", thread=thread)
|
||||
|
||||
mock_executor.run_durable_agent.assert_called_once()
|
||||
_, kwargs = mock_executor.run_durable_agent.call_args
|
||||
assert kwargs["thread"] == thread
|
||||
|
||||
def test_run_forwards_response_format(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None:
|
||||
"""Verify run forwards response_format parameter to executor."""
|
||||
test_agent.run("message", options={"response_format": ResponseFormatModel})
|
||||
|
||||
mock_executor.run_durable_agent.assert_called_once()
|
||||
_, kwargs = mock_executor.run_durable_agent.call_args
|
||||
assert kwargs["run_request"].response_format == ResponseFormatModel
|
||||
|
||||
|
||||
class TestDurableAIAgentProtocolCompliance:
|
||||
"""Test that DurableAIAgent implements AgentProtocol correctly."""
|
||||
|
||||
def test_agent_implements_protocol(self, test_agent: DurableAIAgent[Any]) -> None:
|
||||
"""Verify DurableAIAgent implements AgentProtocol."""
|
||||
assert isinstance(test_agent, AgentProtocol)
|
||||
|
||||
def test_agent_has_required_properties(self, test_agent: DurableAIAgent[Any]) -> None:
|
||||
"""Verify DurableAIAgent has all required AgentProtocol properties."""
|
||||
assert hasattr(test_agent, "id")
|
||||
assert hasattr(test_agent, "name")
|
||||
assert hasattr(test_agent, "display_name")
|
||||
assert hasattr(test_agent, "description")
|
||||
|
||||
def test_agent_id_defaults_to_name(self, mock_executor: Mock) -> None:
|
||||
"""Verify agent id defaults to name when not provided."""
|
||||
agent: DurableAIAgent[Any] = DurableAIAgent(mock_executor, "my_agent")
|
||||
|
||||
assert agent.id == "my_agent"
|
||||
assert agent.name == "my_agent"
|
||||
|
||||
def test_agent_id_can_be_customized(self, mock_executor: Mock) -> None:
|
||||
"""Verify agent id can be set independently from name."""
|
||||
agent: DurableAIAgent[Any] = DurableAIAgent(mock_executor, "my_agent", agent_id="custom-id")
|
||||
|
||||
assert agent.id == "custom-id"
|
||||
assert agent.name == "my_agent"
|
||||
|
||||
|
||||
class TestDurableAIAgentThreadManagement:
|
||||
"""Test thread creation and management."""
|
||||
|
||||
def test_get_new_thread_delegates_to_executor(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None:
|
||||
"""Verify get_new_thread delegates to executor."""
|
||||
mock_thread = DurableAgentThread()
|
||||
mock_executor.get_new_thread.return_value = mock_thread
|
||||
|
||||
thread = test_agent.get_new_thread()
|
||||
|
||||
mock_executor.get_new_thread.assert_called_once_with("test_agent")
|
||||
assert thread == mock_thread
|
||||
|
||||
def test_get_new_thread_forwards_kwargs(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None:
|
||||
"""Verify get_new_thread forwards kwargs to executor."""
|
||||
mock_thread = DurableAgentThread(service_thread_id="thread-123")
|
||||
mock_executor.get_new_thread.return_value = mock_thread
|
||||
|
||||
test_agent.get_new_thread(service_thread_id="thread-123")
|
||||
|
||||
mock_executor.get_new_thread.assert_called_once()
|
||||
_, kwargs = mock_executor.get_new_thread.call_args
|
||||
assert kwargs["service_thread_id"] == "thread-123"
|
||||
|
||||
|
||||
class TestDurableAgentProviderInterface:
|
||||
"""Test that DurableAgentProvider defines the correct interface."""
|
||||
|
||||
def test_provider_cannot_be_instantiated(self) -> None:
|
||||
"""Verify DurableAgentProvider is abstract and cannot be instantiated."""
|
||||
with pytest.raises(TypeError):
|
||||
DurableAgentProvider() # type: ignore[abstract]
|
||||
|
||||
def test_provider_defines_get_agent_method(self) -> None:
|
||||
"""Verify DurableAgentProvider defines get_agent abstract method."""
|
||||
assert hasattr(DurableAgentProvider, "get_agent")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
@@ -0,0 +1,168 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for DurableAIAgentWorker.
|
||||
|
||||
Focuses on critical worker flows: agent registration, validation, callbacks, and lifecycle.
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework_durabletask import DurableAIAgentWorker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_grpc_worker() -> Mock:
|
||||
"""Create a mock TaskHubGrpcWorker for testing."""
|
||||
mock = Mock()
|
||||
mock.add_entity = Mock(return_value="dafx-test_agent")
|
||||
mock.start = Mock()
|
||||
mock.stop = Mock()
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_agent() -> Mock:
|
||||
"""Create a mock agent for testing."""
|
||||
agent = Mock()
|
||||
agent.name = "test_agent"
|
||||
return agent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent_worker(mock_grpc_worker: Mock) -> DurableAIAgentWorker:
|
||||
"""Create a DurableAIAgentWorker with mock worker."""
|
||||
return DurableAIAgentWorker(mock_grpc_worker)
|
||||
|
||||
|
||||
class TestDurableAIAgentWorkerRegistration:
|
||||
"""Test agent registration behavior."""
|
||||
|
||||
def test_add_agent_accepts_agent_with_name(
|
||||
self, agent_worker: DurableAIAgentWorker, mock_agent: Mock, mock_grpc_worker: Mock
|
||||
) -> None:
|
||||
"""Verify that agents with names can be registered."""
|
||||
agent_worker.add_agent(mock_agent)
|
||||
|
||||
# Verify entity was registered with underlying worker
|
||||
mock_grpc_worker.add_entity.assert_called_once()
|
||||
# Verify agent name is tracked
|
||||
assert "test_agent" in agent_worker.registered_agent_names
|
||||
|
||||
def test_add_agent_rejects_agent_without_name(self, agent_worker: DurableAIAgentWorker) -> None:
|
||||
"""Verify that agents without names are rejected."""
|
||||
agent_no_name = Mock()
|
||||
agent_no_name.name = None
|
||||
|
||||
with pytest.raises(ValueError, match="Agent must have a name"):
|
||||
agent_worker.add_agent(agent_no_name)
|
||||
|
||||
def test_add_agent_rejects_empty_name(self, agent_worker: DurableAIAgentWorker) -> None:
|
||||
"""Verify that agents with empty names are rejected."""
|
||||
agent_empty_name = Mock()
|
||||
agent_empty_name.name = ""
|
||||
|
||||
with pytest.raises(ValueError, match="Agent must have a name"):
|
||||
agent_worker.add_agent(agent_empty_name)
|
||||
|
||||
def test_add_agent_rejects_duplicate_names(self, agent_worker: DurableAIAgentWorker, mock_agent: Mock) -> None:
|
||||
"""Verify duplicate agent names are not allowed."""
|
||||
agent_worker.add_agent(mock_agent)
|
||||
|
||||
# Try to register another agent with the same name
|
||||
duplicate_agent = Mock()
|
||||
duplicate_agent.name = "test_agent"
|
||||
|
||||
with pytest.raises(ValueError, match="already registered"):
|
||||
agent_worker.add_agent(duplicate_agent)
|
||||
|
||||
def test_registered_agent_names_tracks_multiple_agents(self, agent_worker: DurableAIAgentWorker) -> None:
|
||||
"""Verify registered_agent_names tracks all registered agents."""
|
||||
agent1 = Mock()
|
||||
agent1.name = "agent1"
|
||||
agent2 = Mock()
|
||||
agent2.name = "agent2"
|
||||
agent3 = Mock()
|
||||
agent3.name = "agent3"
|
||||
|
||||
agent_worker.add_agent(agent1)
|
||||
agent_worker.add_agent(agent2)
|
||||
agent_worker.add_agent(agent3)
|
||||
|
||||
registered = agent_worker.registered_agent_names
|
||||
assert "agent1" in registered
|
||||
assert "agent2" in registered
|
||||
assert "agent3" in registered
|
||||
assert len(registered) == 3
|
||||
|
||||
|
||||
class TestDurableAIAgentWorkerCallbacks:
|
||||
"""Test callback configuration behavior."""
|
||||
|
||||
def test_worker_level_callback_accepted(self, mock_grpc_worker: Mock) -> None:
|
||||
"""Verify worker-level callback can be set."""
|
||||
mock_callback = Mock()
|
||||
agent_worker = DurableAIAgentWorker(mock_grpc_worker, callback=mock_callback)
|
||||
|
||||
assert agent_worker is not None
|
||||
|
||||
def test_agent_level_callback_accepted(self, agent_worker: DurableAIAgentWorker, mock_agent: Mock) -> None:
|
||||
"""Verify agent-level callback can be set during registration."""
|
||||
mock_callback = Mock()
|
||||
|
||||
# Should not raise exception
|
||||
agent_worker.add_agent(mock_agent, callback=mock_callback)
|
||||
|
||||
assert "test_agent" in agent_worker.registered_agent_names
|
||||
|
||||
def test_none_callback_accepted(self, mock_grpc_worker: Mock, mock_agent: Mock) -> None:
|
||||
"""Verify None callback is valid (no callbacks required)."""
|
||||
agent_worker = DurableAIAgentWorker(mock_grpc_worker, callback=None)
|
||||
agent_worker.add_agent(mock_agent, callback=None)
|
||||
|
||||
assert "test_agent" in agent_worker.registered_agent_names
|
||||
|
||||
|
||||
class TestDurableAIAgentWorkerLifecycle:
|
||||
"""Test worker lifecycle behavior."""
|
||||
|
||||
def test_start_delegates_to_underlying_worker(
|
||||
self, agent_worker: DurableAIAgentWorker, mock_grpc_worker: Mock
|
||||
) -> None:
|
||||
"""Verify start() delegates to wrapped worker."""
|
||||
agent_worker.start()
|
||||
|
||||
mock_grpc_worker.start.assert_called_once()
|
||||
|
||||
def test_stop_delegates_to_underlying_worker(
|
||||
self, agent_worker: DurableAIAgentWorker, mock_grpc_worker: Mock
|
||||
) -> None:
|
||||
"""Verify stop() delegates to wrapped worker."""
|
||||
agent_worker.stop()
|
||||
|
||||
mock_grpc_worker.stop.assert_called_once()
|
||||
|
||||
def test_start_works_with_no_agents(self, agent_worker: DurableAIAgentWorker, mock_grpc_worker: Mock) -> None:
|
||||
"""Verify worker can start even with no agents registered."""
|
||||
agent_worker.start()
|
||||
|
||||
mock_grpc_worker.start.assert_called_once()
|
||||
|
||||
def test_start_works_with_multiple_agents(self, agent_worker: DurableAIAgentWorker, mock_grpc_worker: Mock) -> None:
|
||||
"""Verify worker can start with multiple agents registered."""
|
||||
agent1 = Mock()
|
||||
agent1.name = "agent1"
|
||||
agent2 = Mock()
|
||||
agent2.name = "agent2"
|
||||
|
||||
agent_worker.add_agent(agent1)
|
||||
agent_worker.add_agent(agent2)
|
||||
agent_worker.start()
|
||||
|
||||
mock_grpc_worker.start.assert_called_once()
|
||||
assert len(agent_worker.registered_agent_names) == 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
@@ -4,7 +4,7 @@ description = "Foundry Local integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260123"
|
||||
version = "1.0.0b260116"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user