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*
|
**/tmpclaude*
|
||||||
|
|
||||||
# Azurite storage emulator files
|
# Azurite storage emulator files
|
||||||
*/__azurite_db_blob__.json
|
*/__azurite_db_blob__.json*
|
||||||
*/__azurite_db_blob_extent__.json
|
*/__azurite_db_blob_extent__.json*
|
||||||
*/__azurite_db_queue__.json
|
*/__azurite_db_queue__.json*
|
||||||
*/__azurite_db_queue_extent__.json
|
*/__azurite_db_queue_extent__.json*
|
||||||
*/__azurite_db_table__.json
|
*/__azurite_db_table__.json*
|
||||||
*/__blobstorage__/
|
*/__blobstorage__/
|
||||||
*/__queuestorage__/
|
*/__queuestorage__/
|
||||||
|
*/AzuriteConfig
|
||||||
|
|
||||||
# Azure Functions local settings
|
# Azure Functions local settings
|
||||||
local.settings.json
|
local.settings.json
|
||||||
|
|||||||
@@ -6,27 +6,16 @@ using Microsoft.Shared.Diagnostics;
|
|||||||
namespace Microsoft.Agents.AI.Workflows;
|
namespace Microsoft.Agents.AI.Workflows;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <param name="Agent">The AI agent.</param>
|
/// <param name="Agent">The AI agent.</param>
|
||||||
/// <param name="Options">The options for configuring the AI agent host.
|
/// <param name="EmitEvents">Specifies whether the agent should emit events. If null, the default behavior is applied.</param>
|
||||||
/// </param>
|
public record AIAgentBinding(AIAgent Agent, bool EmitEvents = false)
|
||||||
public record AIAgentBinding(AIAgent Agent, AIAgentHostOptions? Options = null)
|
|
||||||
: ExecutorBinding(Throw.IfNull(Agent).GetDescriptiveId(),
|
: ExecutorBinding(Throw.IfNull(Agent).GetDescriptiveId(),
|
||||||
(_) => new(new AIAgentHostExecutor(Agent, Options ?? new())),
|
(_) => new(new AIAgentHostExecutor(Agent, EmitEvents)),
|
||||||
typeof(AIAgentHostExecutor),
|
typeof(AIAgentHostExecutor),
|
||||||
Agent)
|
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/>
|
/// <inheritdoc/>
|
||||||
public override bool IsSharedInstance => false;
|
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.
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
@@ -20,29 +19,6 @@ internal static class AIAgentsAbstractionsExtensions
|
|||||||
RawRepresentation = update.RawRepresentation ?? update,
|
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>
|
/// <summary>
|
||||||
/// Iterates through <paramref name="messages"/> looking for <see cref="ChatRole.Assistant"/> messages and swapping
|
/// 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
|
/// 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;
|
List<ChatMessage>? roleChanged = null;
|
||||||
foreach (var m in messages)
|
foreach (var m in messages)
|
||||||
{
|
{
|
||||||
m.ChatAssistantToUserIfNotFromNamed(targetAgentName, out bool changed);
|
if (m.Role == ChatRole.Assistant &&
|
||||||
if (changed)
|
m.AuthorName != targetAgentName &&
|
||||||
|
m.Contents.All(c => c is TextContent or DataContent or UriContent or UsageContent))
|
||||||
{
|
{
|
||||||
|
m.Role = ChatRole.User;
|
||||||
(roleChanged ??= []).Add(m);
|
(roleChanged ??= []).Add(m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.Agents.AI.Workflows.Specialized;
|
using Microsoft.Agents.AI.Workflows.Specialized;
|
||||||
@@ -34,27 +35,37 @@ public static partial class AgentWorkflowBuilder
|
|||||||
|
|
||||||
private static Workflow BuildSequentialCore(string? workflowName, params IEnumerable<AIAgent> agents)
|
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
|
// Create a builder that chains the agents together in sequence. The workflow simply begins
|
||||||
// with the first agent in the sequence.
|
// with the first agent in the sequence.
|
||||||
|
WorkflowBuilder? builder = null;
|
||||||
AIAgentHostOptions options = new()
|
ExecutorBinding? previous = null;
|
||||||
|
foreach (var agent in agents)
|
||||||
{
|
{
|
||||||
ReassignOtherAgentsAsUsers = true,
|
AgentRunStreamingExecutor agentExecutor = new(agent, includeInputInOutput: true);
|
||||||
ForwardIncomingMessages = true,
|
|
||||||
};
|
|
||||||
|
|
||||||
List<ExecutorBinding> agentExecutors = agents.Select(agent => agent.BindAsExecutor(options)).ToList();
|
if (builder is null)
|
||||||
|
|
||||||
ExecutorBinding previous = agentExecutors[0];
|
|
||||||
WorkflowBuilder builder = new(previous);
|
|
||||||
|
|
||||||
foreach (ExecutorBinding next in agentExecutors.Skip(1))
|
|
||||||
{
|
{
|
||||||
builder.AddEdge(previous, next);
|
builder = new WorkflowBuilder(agentExecutor);
|
||||||
previous = next;
|
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Debug.Assert(previous is not null);
|
||||||
|
builder.AddEdge(previous, agentExecutor);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
OutputMessagesExecutor end = new();
|
||||||
builder = builder.AddEdge(previous, end).WithOutputFrom(end);
|
builder = builder.AddEdge(previous, end).WithOutputFrom(end);
|
||||||
@@ -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
|
// 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
|
// 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.
|
// provenance tracking exposed in the workflow context passed to a handler.
|
||||||
|
ExecutorBinding[] agentExecutors = (from agent in agents select (ExecutorBinding)new AgentRunStreamingExecutor(agent, includeInputInOutput: false)).ToArray();
|
||||||
ExecutorBinding[] agentExecutors = (from agent in agents
|
ExecutorBinding[] accumulators = [.. from agent in agentExecutors select (ExecutorBinding)new CollectChatMessagesExecutor($"Batcher/{agent.Id}")];
|
||||||
select agent.BindAsExecutor(new AIAgentHostOptions() { ReassignOtherAgentsAsUsers = true })).ToArray();
|
|
||||||
ExecutorBinding[] accumulators = [.. from agent in agentExecutors select (ExecutorBinding)new AggregateTurnMessagesExecutor($"Batcher/{agent.Id}")];
|
|
||||||
builder.AddFanOutEdge(start, agentExecutors);
|
builder.AddFanOutEdge(start, agentExecutors);
|
||||||
|
|
||||||
for (int i = 0; i < agentExecutors.Length; i++)
|
for (int i = 0; i < agentExecutors.Length; i++)
|
||||||
{
|
{
|
||||||
builder.AddEdge(agentExecutors[i], accumulators[i]);
|
builder.AddEdge(agentExecutors[i], accumulators[i]);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.Extensions.AI;
|
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.
|
/// If set, the executor will accept string messages and convert them to chat messages with this role.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ChatRole? StringMessageChatRole { get; set; }
|
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>
|
/// <summary>
|
||||||
@@ -33,8 +26,8 @@ public class ChatProtocolExecutorOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public abstract class ChatProtocolExecutor : StatefulExecutor<List<ChatMessage>>
|
public abstract class ChatProtocolExecutor : StatefulExecutor<List<ChatMessage>>
|
||||||
{
|
{
|
||||||
internal static readonly Func<List<ChatMessage>> s_initFunction = () => [];
|
private static readonly Func<List<ChatMessage>> s_initFunction = () => [];
|
||||||
private readonly ChatProtocolExecutorOptions _options;
|
private readonly ChatRole? _stringMessageChatRole;
|
||||||
|
|
||||||
private static readonly StatefulExecutorOptions s_baseExecutorOptions = new()
|
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)
|
protected ChatProtocolExecutor(string id, ChatProtocolExecutorOptions? options = null, bool declareCrossRunShareable = false)
|
||||||
: base(id, () => [], s_baseExecutorOptions, declareCrossRunShareable)
|
: 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/>
|
/// <inheritdoc/>
|
||||||
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
|
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
|
||||||
{
|
{
|
||||||
if (this.SupportsStringMessage)
|
if (this._stringMessageChatRole.HasValue)
|
||||||
{
|
{
|
||||||
routeBuilder = routeBuilder.AddHandler<string>(
|
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)
|
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)
|
await this.TakeTurnAsync(maybePendingMessages ?? s_initFunction(), context, token.EmitEvents, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.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,
|
// 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.)
|
// 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>
|
/// <summary>
|
||||||
/// When overridden in a derived class, processes the accumulated chat messages for a single turn.
|
/// When overridden in a derived class, processes the accumulated chat messages for a single turn.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// Copyright (c) Microsoft. All rights reserved.
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -13,7 +12,7 @@ internal sealed class EdgeMap
|
|||||||
{
|
{
|
||||||
private readonly Dictionary<EdgeId, EdgeRunner> _edgeRunners = [];
|
private readonly Dictionary<EdgeId, EdgeRunner> _edgeRunners = [];
|
||||||
private readonly Dictionary<EdgeId, IStatefulEdgeRunner> _statefulRunners = [];
|
private readonly Dictionary<EdgeId, IStatefulEdgeRunner> _statefulRunners = [];
|
||||||
private readonly ConcurrentDictionary<string, ResponseEdgeRunner> _portEdgeRunners;
|
private readonly Dictionary<string, ResponseEdgeRunner> _portEdgeRunners;
|
||||||
|
|
||||||
private readonly ResponseEdgeRunner _inputRunner;
|
private readonly ResponseEdgeRunner _inputRunner;
|
||||||
private readonly IStepTracer? _stepTracer;
|
private readonly IStepTracer? _stepTracer;
|
||||||
@@ -52,16 +51,12 @@ internal sealed class EdgeMap
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this._portEdgeRunners = new();
|
this._portEdgeRunners = workflowPorts.ToDictionary(
|
||||||
foreach (RequestPort port in workflowPorts)
|
port => port.Id,
|
||||||
{
|
port => ResponseEdgeRunner.ForPort(runContext, port)
|
||||||
if (!this.TryRegisterPort(runContext, port.Id, port))
|
);
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Duplicate port ID detected: {port.Id}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this._inputRunner = new ResponseEdgeRunner(runContext, startExecutorId, "");
|
this._inputRunner = new ResponseEdgeRunner(runContext, startExecutorId);
|
||||||
this._stepTracer = stepTracer;
|
this._stepTracer = stepTracer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,9 +71,6 @@ internal sealed class EdgeMap
|
|||||||
return edgeRunner.ChaseEdgeAsync(message, this._stepTracer);
|
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)
|
public ValueTask<DeliveryMapping?> PrepareDeliveryForInputAsync(MessageEnvelope message)
|
||||||
{
|
{
|
||||||
return this._inputRunner.ChaseEdgeAsync(message, this._stepTracer);
|
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 SendMessageAsync(string sourceId, object message, string? targetId = null, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
ValueTask<StepContext> AdvanceAsync(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);
|
ValueTask<Executor> EnsureExecutorAsync(string executorId, IStepTracer? tracer, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,19 +8,17 @@ using Microsoft.Shared.Diagnostics;
|
|||||||
|
|
||||||
namespace Microsoft.Agents.AI.Workflows.Execution;
|
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)
|
: 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);
|
Throw.IfNull(port);
|
||||||
|
|
||||||
// The port is an request port, so we can use the port's ID as the sink ID.
|
// 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)
|
protected internal override async ValueTask<DeliveryMapping?> ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer)
|
||||||
{
|
{
|
||||||
Debug.Assert(envelope.IsExternal, "Input edges should only be chased from external input");
|
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?
|
activity?
|
||||||
.SetTag(Tags.EdgeGroupType, nameof(ResponseEdgeRunner))
|
.SetTag(Tags.EdgeGroupType, nameof(ResponseEdgeRunner))
|
||||||
.SetTag(Tags.MessageSourceId, envelope.SourceId)
|
.SetTag(Tags.MessageSourceId, envelope.SourceId)
|
||||||
.SetTag(Tags.MessageTargetId, $"{this.ExecutorId}[{this.EdgeData}]");
|
.SetTag(Tags.MessageTargetId, this.EdgeData);
|
||||||
|
|
||||||
try
|
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>
|
/// <summary>
|
||||||
/// A component that processes messages in a <see cref="Workflow"/>.
|
/// A component that processes messages in a <see cref="Workflow"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[DebuggerDisplay("{GetType().Name}[{Id}]")]
|
[DebuggerDisplay("{GetType().Name}{Id}")]
|
||||||
public abstract class Executor : IIdentified
|
public abstract class Executor : IIdentified
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -63,24 +63,6 @@ public abstract class Executor : IIdentified
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
protected abstract RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder);
|
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>
|
/// <summary>
|
||||||
/// Perform any asynchronous initialization required by the executor. This method is called once per executor instance,
|
/// Perform any asynchronous initialization required by the executor. This method is called once per executor instance,
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -117,15 +99,12 @@ public abstract class Executor : IIdentified
|
|||||||
{
|
{
|
||||||
if (field is null)
|
if (field is null)
|
||||||
{
|
{
|
||||||
field = this.CreateRouter();
|
RouteBuilder routeBuilder = this.ConfigureRoutes(new RouteBuilder());
|
||||||
|
field = routeBuilder.Build();
|
||||||
}
|
}
|
||||||
|
|
||||||
return field;
|
return field;
|
||||||
}
|
}
|
||||||
private set
|
|
||||||
{
|
|
||||||
field = value;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -419,18 +419,9 @@ public static class ExecutorBindingExtensions
|
|||||||
/// <param name="agent">The agent instance.</param>
|
/// <param name="agent">The agent instance.</param>
|
||||||
/// <param name="emitEvents">Specifies whether the agent should emit streaming events.</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>
|
/// <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);
|
=> 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>
|
/// <summary>
|
||||||
/// Configure a <see cref="RequestPort"/> as an executor for use in a workflow.
|
/// Configure a <see cref="RequestPort"/> as an executor for use in a workflow.
|
||||||
/// </summary>
|
/// </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>
|
/// <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>
|
/// <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);
|
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()
|
public Workflow Build()
|
||||||
{
|
{
|
||||||
AIAgent[] agents = this._participants.ToArray();
|
AIAgent[] agents = this._participants.ToArray();
|
||||||
|
Dictionary<AIAgent, ExecutorBinding> agentMap = agents.ToDictionary(a => a, a => (ExecutorBinding)new AgentRunStreamingExecutor(a, includeInputInOutput: true));
|
||||||
AIAgentHostOptions options = new()
|
|
||||||
{
|
|
||||||
ReassignOtherAgentsAsUsers = true,
|
|
||||||
ForwardIncomingMessages = true
|
|
||||||
};
|
|
||||||
|
|
||||||
Dictionary<AIAgent, ExecutorBinding> agentMap = agents.ToDictionary(a => a, a => a.BindAsExecutor(options));
|
|
||||||
|
|
||||||
Func<string, string, ValueTask<Executor>> groupChatHostFactory =
|
Func<string, string, ValueTask<Executor>> groupChatHostFactory =
|
||||||
(id, runId) => new(new GroupChatHost(id, agents, agentMap, this._managerFactory));
|
(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(
|
await executor.ExecuteAsync(
|
||||||
envelope.Message,
|
envelope.Message,
|
||||||
envelope.MessageType,
|
envelope.MessageType,
|
||||||
this.RunContext.BindWorkflowContext(receiverId, envelope.TraceContext),
|
this.RunContext.Bind(receiverId, envelope.TraceContext),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
).ConfigureAwait(false);
|
).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,16 +71,6 @@ internal sealed class InProcessRunnerContext : IRunnerContext
|
|||||||
this.OutgoingEvents = outgoingEvents;
|
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)
|
public async ValueTask<Executor> EnsureExecutorAsync(string executorId, IStepTracer? tracer, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
this.CheckEnded();
|
this.CheckEnded();
|
||||||
@@ -94,9 +84,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext
|
|||||||
}
|
}
|
||||||
|
|
||||||
Executor executor = await registration.CreateInstanceAsync(this._runId).ConfigureAwait(false);
|
Executor executor = await registration.CreateInstanceAsync(this._runId).ConfigureAwait(false);
|
||||||
executor.Configure(this.BindExternalRequestContext(executorId));
|
await executor.InitializeAsync(this.Bind(executorId), cancellationToken: cancellationToken)
|
||||||
|
|
||||||
await executor.InitializeAsync(this.BindWorkflowContext(executorId), cancellationToken: cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
tracer?.TraceActivated(executorId);
|
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();
|
this.CheckEnded();
|
||||||
return new BoundExternalRequestContext(this, executorId);
|
return new BoundContext(this, executorId, traceContext);
|
||||||
}
|
|
||||||
|
|
||||||
public IWorkflowContext BindWorkflowContext(string executorId, Dictionary<string, string>? traceContext = null)
|
|
||||||
{
|
|
||||||
this.CheckEnded();
|
|
||||||
return new BoundWorkflowContext(this, executorId, traceContext);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public ValueTask PostAsync(ExternalRequest request)
|
public ValueTask PostAsync(ExternalRequest request)
|
||||||
@@ -278,17 +260,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext
|
|||||||
|
|
||||||
internal StateManager StateManager { get; } = new();
|
internal StateManager StateManager { get; } = new();
|
||||||
|
|
||||||
private sealed class BoundExternalRequestContext(
|
private sealed class BoundContext(
|
||||||
InProcessRunnerContext RunnerContext,
|
|
||||||
string ExecutorId) : IExternalRequestContext
|
|
||||||
{
|
|
||||||
public IExternalRequestSink RegisterPort(RequestPort port)
|
|
||||||
{
|
|
||||||
return RunnerContext.RegisterPort(ExecutorId, port);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class BoundWorkflowContext(
|
|
||||||
InProcessRunnerContext RunnerContext,
|
InProcessRunnerContext RunnerContext,
|
||||||
string ExecutorId,
|
string ExecutorId,
|
||||||
Dictionary<string, string>? traceContext) : IWorkflowContext
|
Dictionary<string, string>? traceContext) : IWorkflowContext
|
||||||
@@ -340,7 +312,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext
|
|||||||
async Task InvokeCheckpointingAsync(Task<Executor> executorTask)
|
async Task InvokeCheckpointingAsync(Task<Executor> executorTask)
|
||||||
{
|
{
|
||||||
Executor executor = await executorTask.ConfigureAwait(false);
|
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)
|
async Task InvokeCheckpointRestoredAsync(Task<Executor> executorTask)
|
||||||
{
|
{
|
||||||
Executor executor = await executorTask.ConfigureAwait(false);
|
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>
|
<PropertyGroup>
|
||||||
<VersionSuffix>preview</VersionSuffix>
|
<VersionSuffix>preview</VersionSuffix>
|
||||||
<NoWarn>$(NoWarn);MEAI001</NoWarn>
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<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>
|
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;
|
namespace Microsoft.Agents.AI.Workflows;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -40,17 +32,10 @@ namespace Microsoft.Agents.AI.Workflows;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public class RouteBuilder
|
public class RouteBuilder
|
||||||
{
|
{
|
||||||
private readonly IExternalRequestContext? _externalRequestContext;
|
|
||||||
private readonly Dictionary<Type, MessageHandlerF> _typedHandlers = [];
|
private readonly Dictionary<Type, MessageHandlerF> _typedHandlers = [];
|
||||||
private readonly Dictionary<Type, Type> _outputTypes = [];
|
private readonly Dictionary<Type, Type> _outputTypes = [];
|
||||||
private readonly Dictionary<string, PortHandlerF> _portHandlers = [];
|
|
||||||
private CatchAllF? _catchAll;
|
private CatchAllF? _catchAll;
|
||||||
|
|
||||||
internal RouteBuilder(IExternalRequestContext? externalRequestContext)
|
|
||||||
{
|
|
||||||
this._externalRequestContext = externalRequestContext;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal RouteBuilder AddHandlerInternal(Type messageType, MessageHandlerF handler, Type? outputType, bool overwrite = false)
|
internal RouteBuilder AddHandlerInternal(Type messageType, MessageHandlerF handler, Type? outputType, bool overwrite = false)
|
||||||
{
|
{
|
||||||
Throw.IfNull(messageType);
|
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>
|
/// <summary>
|
||||||
/// Registers a handler for messages of the specified input type in the workflow route.
|
/// Registers a handler for messages of the specified input type in the workflow route.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -180,12 +111,10 @@ public class RouteBuilder
|
|||||||
/// <typeparam name="TInput"></typeparam>
|
/// <typeparam name="TInput"></typeparam>
|
||||||
/// <param name="handler">A delegate that processes messages of type <typeparamref name="TInput"/> within the workflow context. The
|
/// <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>
|
/// 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
|
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the specified input type; otherwise, <see
|
||||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
/// langword="false"/> to preserve the existing handler.</param>
|
||||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of additional handlers or route
|
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of additional handlers or route
|
||||||
/// options.</returns>
|
/// 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)
|
public RouteBuilder AddHandler<TInput>(Action<TInput, IWorkflowContext, CancellationToken> handler, bool overwrite = false)
|
||||||
{
|
{
|
||||||
Throw.IfNull(handler);
|
Throw.IfNull(handler);
|
||||||
@@ -208,12 +137,10 @@ public class RouteBuilder
|
|||||||
/// <typeparam name="TInput"></typeparam>
|
/// <typeparam name="TInput"></typeparam>
|
||||||
/// <param name="handler">A delegate that processes messages of type <typeparamref name="TInput"/> within the workflow context. The
|
/// <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>
|
/// 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
|
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the specified input type; otherwise, <see
|
||||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
/// langword="false"/> to preserve the existing handler.</param>
|
||||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of additional handlers or route
|
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of additional handlers or route
|
||||||
/// options.</returns>
|
/// 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)
|
public RouteBuilder AddHandler<TInput>(Action<TInput, IWorkflowContext> handler, bool overwrite = false)
|
||||||
{
|
{
|
||||||
Throw.IfNull(handler);
|
Throw.IfNull(handler);
|
||||||
@@ -236,12 +163,10 @@ public class RouteBuilder
|
|||||||
/// <typeparam name="TInput"></typeparam>
|
/// <typeparam name="TInput"></typeparam>
|
||||||
/// <param name="handler">A delegate that processes messages of type <typeparamref name="TInput"/> within the workflow context. The
|
/// <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>
|
/// 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
|
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the specified input type; otherwise, <see
|
||||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
/// langword="false"/> to preserve the existing handler.</param>
|
||||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of additional handlers or route
|
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of additional handlers or route
|
||||||
/// options.</returns>
|
/// 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)
|
public RouteBuilder AddHandler<TInput>(Func<TInput, IWorkflowContext, CancellationToken, ValueTask> handler, bool overwrite = false)
|
||||||
{
|
{
|
||||||
Throw.IfNull(handler);
|
Throw.IfNull(handler);
|
||||||
@@ -264,12 +189,10 @@ public class RouteBuilder
|
|||||||
/// <typeparam name="TInput"></typeparam>
|
/// <typeparam name="TInput"></typeparam>
|
||||||
/// <param name="handler">A delegate that processes messages of type <typeparamref name="TInput"/> within the workflow context. The
|
/// <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>
|
/// 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
|
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the specified input type; otherwise, <see
|
||||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
/// langword="false"/> to preserve the existing handler.</param>
|
||||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of additional handlers or route
|
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of additional handlers or route
|
||||||
/// options.</returns>
|
/// 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)
|
public RouteBuilder AddHandler<TInput>(Func<TInput, IWorkflowContext, ValueTask> handler, bool overwrite = false)
|
||||||
{
|
{
|
||||||
Throw.IfNull(handler);
|
Throw.IfNull(handler);
|
||||||
@@ -293,11 +216,9 @@ public class RouteBuilder
|
|||||||
/// <typeparam name="TResult">The type of result produced by the handler.</typeparam>
|
/// <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
|
/// <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>
|
/// 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
|
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
|
||||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
/// preserve existing handlers.</param>
|
||||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
|
/// <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)
|
public RouteBuilder AddHandler<TInput, TResult>(Func<TInput, IWorkflowContext, CancellationToken, TResult> handler, bool overwrite = false)
|
||||||
{
|
{
|
||||||
Throw.IfNull(handler);
|
Throw.IfNull(handler);
|
||||||
@@ -321,11 +242,9 @@ public class RouteBuilder
|
|||||||
/// <typeparam name="TResult">The type of result produced by the handler.</typeparam>
|
/// <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
|
/// <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>
|
/// 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
|
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
|
||||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
/// preserve existing handlers.</param>
|
||||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
|
/// <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)
|
public RouteBuilder AddHandler<TInput, TResult>(Func<TInput, IWorkflowContext, TResult> handler, bool overwrite = false)
|
||||||
{
|
{
|
||||||
Throw.IfNull(handler);
|
Throw.IfNull(handler);
|
||||||
@@ -349,11 +268,9 @@ public class RouteBuilder
|
|||||||
/// <typeparam name="TResult">The type of result produced by the handler.</typeparam>
|
/// <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
|
/// <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>
|
/// 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
|
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
|
||||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
/// preserve existing handlers.</param>
|
||||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
|
/// <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)
|
public RouteBuilder AddHandler<TInput, TResult>(Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TResult>> handler, bool overwrite = false)
|
||||||
{
|
{
|
||||||
Throw.IfNull(handler);
|
Throw.IfNull(handler);
|
||||||
@@ -362,7 +279,7 @@ public class RouteBuilder
|
|||||||
|
|
||||||
async ValueTask<CallResult> WrappedHandlerAsync(object message, IWorkflowContext context, CancellationToken cancellationToken)
|
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);
|
return CallResult.ReturnResult(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -377,11 +294,9 @@ public class RouteBuilder
|
|||||||
/// <typeparam name="TResult">The type of result produced by the handler.</typeparam>
|
/// <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
|
/// <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>
|
/// 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
|
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
|
||||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
/// preserve existing handlers.</param>
|
||||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
|
/// <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)
|
public RouteBuilder AddHandler<TInput, TResult>(Func<TInput, IWorkflowContext, ValueTask<TResult>> handler, bool overwrite = false)
|
||||||
{
|
{
|
||||||
Throw.IfNull(handler);
|
Throw.IfNull(handler);
|
||||||
@@ -415,11 +330,9 @@ public class RouteBuilder
|
|||||||
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
|
/// 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
|
/// <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>
|
/// 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
|
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
|
||||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
/// preserve existing handlers.</param>
|
||||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
|
/// <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)
|
public RouteBuilder AddCatchAll(Func<PortableValue, IWorkflowContext, CancellationToken, ValueTask> handler, bool overwrite = false)
|
||||||
{
|
{
|
||||||
Throw.IfNull(handler);
|
Throw.IfNull(handler);
|
||||||
@@ -441,11 +354,9 @@ public class RouteBuilder
|
|||||||
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
|
/// 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
|
/// <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>
|
/// 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
|
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
|
||||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
/// preserve existing handlers.</param>
|
||||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
|
/// <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)
|
public RouteBuilder AddCatchAll(Func<PortableValue, IWorkflowContext, ValueTask> handler, bool overwrite = false)
|
||||||
{
|
{
|
||||||
Throw.IfNull(handler);
|
Throw.IfNull(handler);
|
||||||
@@ -467,11 +378,9 @@ public class RouteBuilder
|
|||||||
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
|
/// 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
|
/// <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>
|
/// 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
|
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
|
||||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
/// preserve existing handlers.</param>
|
||||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
|
/// <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)
|
public RouteBuilder AddCatchAll<TResult>(Func<PortableValue, IWorkflowContext, CancellationToken, ValueTask<TResult>> handler, bool overwrite = false)
|
||||||
{
|
{
|
||||||
Throw.IfNull(handler);
|
Throw.IfNull(handler);
|
||||||
@@ -493,11 +402,9 @@ public class RouteBuilder
|
|||||||
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
|
/// 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
|
/// <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>
|
/// 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
|
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
|
||||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
/// preserve existing handlers.</param>
|
||||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
|
/// <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)
|
public RouteBuilder AddCatchAll<TResult>(Func<PortableValue, IWorkflowContext, ValueTask<TResult>> handler, bool overwrite = false)
|
||||||
{
|
{
|
||||||
Throw.IfNull(handler);
|
Throw.IfNull(handler);
|
||||||
@@ -519,11 +426,9 @@ public class RouteBuilder
|
|||||||
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
|
/// 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
|
/// <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>
|
/// 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
|
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
|
||||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
/// preserve existing handlers.</param>
|
||||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
|
/// <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)
|
public RouteBuilder AddCatchAll(Action<PortableValue, IWorkflowContext, CancellationToken> handler, bool overwrite = false)
|
||||||
{
|
{
|
||||||
Throw.IfNull(handler);
|
Throw.IfNull(handler);
|
||||||
@@ -545,11 +450,9 @@ public class RouteBuilder
|
|||||||
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
|
/// 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
|
/// <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>
|
/// 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
|
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
|
||||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
/// preserve existing handlers.</param>
|
||||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
|
/// <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)
|
public RouteBuilder AddCatchAll(Action<PortableValue, IWorkflowContext> handler, bool overwrite = false)
|
||||||
{
|
{
|
||||||
Throw.IfNull(handler);
|
Throw.IfNull(handler);
|
||||||
@@ -571,11 +474,9 @@ public class RouteBuilder
|
|||||||
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
|
/// 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
|
/// <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>
|
/// 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
|
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
|
||||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
/// preserve existing handlers.</param>
|
||||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
|
/// <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)
|
public RouteBuilder AddCatchAll<TResult>(Func<PortableValue, IWorkflowContext, CancellationToken, TResult> handler, bool overwrite = false)
|
||||||
{
|
{
|
||||||
Throw.IfNull(handler);
|
Throw.IfNull(handler);
|
||||||
@@ -597,11 +498,9 @@ public class RouteBuilder
|
|||||||
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
|
/// 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
|
/// <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>
|
/// 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
|
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
|
||||||
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
|
/// preserve existing handlers.</param>
|
||||||
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
|
/// <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)
|
public RouteBuilder AddCatchAll<TResult>(Func<PortableValue, IWorkflowContext, TResult> handler, bool overwrite = false)
|
||||||
{
|
{
|
||||||
Throw.IfNull(handler);
|
Throw.IfNull(handler);
|
||||||
@@ -615,29 +514,5 @@ public class RouteBuilder
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RegisterPortHandlerRouter()
|
internal MessageRouter Build() => new(this._typedHandlers, [.. this._outputTypes.Values], this._catchAll);
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
// Copyright (c) Microsoft. All rights reserved.
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -10,168 +8,51 @@ using Microsoft.Extensions.AI;
|
|||||||
|
|
||||||
namespace Microsoft.Agents.AI.Workflows.Specialized;
|
namespace Microsoft.Agents.AI.Workflows.Specialized;
|
||||||
|
|
||||||
internal record AIAgentHostState(JsonElement? ThreadState, bool? CurrentTurnEmitEvents);
|
|
||||||
|
|
||||||
internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
|
internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
|
||||||
{
|
{
|
||||||
|
private readonly bool _emitEvents;
|
||||||
private readonly AIAgent _agent;
|
private readonly AIAgent _agent;
|
||||||
private readonly AIAgentHostOptions _options;
|
|
||||||
|
|
||||||
private AgentThread? _thread;
|
private AgentThread? _thread;
|
||||||
private bool? _currentTurnEmitEvents;
|
|
||||||
|
|
||||||
private AIContentExternalHandler<UserInputRequestContent, UserInputResponseContent>? _userInputHandler;
|
public AIAgentHostExecutor(AIAgent agent, bool emitEvents = false) : base(id: agent.GetDescriptiveId())
|
||||||
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
|
|
||||||
{
|
{
|
||||||
this._agent = agent;
|
this._agent = agent;
|
||||||
this._options = options;
|
this._emitEvents = emitEvents;
|
||||||
}
|
}
|
||||||
|
|
||||||
private RouteBuilder ConfigureUserInputRoutes(RouteBuilder routeBuilder)
|
private async Task<AgentThread> EnsureThreadAsync(IWorkflowContext context, CancellationToken cancellationToken) =>
|
||||||
{
|
|
||||||
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) =>
|
|
||||||
this._thread ??= await this._agent.GetNewThreadAsync(cancellationToken).ConfigureAwait(false);
|
this._thread ??= await this._agent.GetNewThreadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
private const string UserInputRequestStateKey = nameof(_userInputHandler);
|
private const string ThreadStateKey = nameof(_thread);
|
||||||
private const string FunctionCallRequestStateKey = nameof(_functionCallHandler);
|
|
||||||
private const string AIAgentHostStateKey = nameof(AIAgentHostState);
|
|
||||||
|
|
||||||
protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
AIAgentHostState state = new(this._thread?.Serialize(), this._currentTurnEmitEvents);
|
Task threadTask = Task.CompletedTask;
|
||||||
Task coreStateTask = context.QueueStateUpdateAsync(AIAgentHostStateKey, state, cancellationToken: cancellationToken).AsTask();
|
if (this._thread is not null)
|
||||||
Task userInputRequestsTask = this._userInputHandler?.OnCheckpointingAsync(UserInputRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
|
{
|
||||||
Task functionCallRequestsTask = this._functionCallHandler?.OnCheckpointingAsync(FunctionCallRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
|
JsonElement threadValue = this._thread.Serialize();
|
||||||
|
threadTask = context.QueueStateUpdateAsync(ThreadStateKey, threadValue, cancellationToken: cancellationToken).AsTask();
|
||||||
|
}
|
||||||
|
|
||||||
Task baseTask = base.OnCheckpointingAsync(context, 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)
|
protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
Task userInputRestoreTask = this._userInputHandler?.OnCheckpointRestoredAsync(UserInputRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
|
JsonElement? threadValue = await context.ReadStateAsync<JsonElement?>(ThreadStateKey, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||||
Task functionCallRestoreTask = this._functionCallHandler?.OnCheckpointRestoredAsync(FunctionCallRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
|
if (threadValue.HasValue)
|
||||||
|
|
||||||
AIAgentHostState? state = await context.ReadStateAsync<AIAgentHostState>(AIAgentHostStateKey, cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
||||||
if (state != null)
|
|
||||||
{
|
{
|
||||||
this._thread = state.ThreadState.HasValue
|
this._thread = await this._agent.DeserializeThreadAsync(threadValue.Value, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||||
? await this._agent.DeserializeThreadAsync(state.ThreadState.Value, cancellationToken: cancellationToken).ConfigureAwait(false)
|
|
||||||
: null;
|
|
||||||
this._currentTurnEmitEvents = state.CurrentTurnEmitEvents;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.WhenAll(userInputRestoreTask, functionCallRestoreTask).ConfigureAwait(false);
|
|
||||||
await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false);
|
await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool HasOutstandingRequests => (this._userInputHandler?.HasPendingRequests == true)
|
protected override async ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
|
||||||
|| (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)
|
|
||||||
{
|
{
|
||||||
this._currentTurnEmitEvents = emitEvents;
|
if (emitEvents ?? this._emitEvents)
|
||||||
if (this._options.ForwardIncomingMessages)
|
|
||||||
{
|
{
|
||||||
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.
|
// Run the agent in streaming mode only when agent run update events are to be emitted.
|
||||||
IAsyncEnumerable<AgentResponseUpdate> agentStream = this._agent.RunStreamingAsync(
|
IAsyncEnumerable<AgentResponseUpdate> agentStream = this._agent.RunStreamingAsync(
|
||||||
messages,
|
messages,
|
||||||
@@ -179,70 +60,28 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
|
|||||||
cancellationToken: cancellationToken);
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
List<AgentResponseUpdate> updates = [];
|
List<AgentResponseUpdate> updates = [];
|
||||||
|
|
||||||
await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false))
|
await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).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);
|
updates.Add(update);
|
||||||
}
|
}
|
||||||
|
|
||||||
response = updates.ToAgentResponse();
|
await context.SendMessageAsync(updates.ToAgentResponse().Messages, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Otherwise, run the agent in non-streaming mode.
|
// Otherwise, run the agent in non-streaming mode.
|
||||||
response = await this._agent.RunAsync(messages,
|
AgentResponse response = await this._agent.RunAsync(
|
||||||
|
messages,
|
||||||
await this.EnsureThreadAsync(context, cancellationToken).ConfigureAwait(false),
|
await this.EnsureThreadAsync(context, cancellationToken).ConfigureAwait(false),
|
||||||
cancellationToken: cancellationToken)
|
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||||
.ConfigureAwait(false);
|
await context.SendMessageAsync(response.Messages, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
ExtractUnservicedRequests(response.Messages.SelectMany(message => message.Contents));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
namespace Microsoft.Agents.AI.Workflows.Specialized;
|
||||||
|
|
||||||
/// <summary>
|
/// <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"/>.
|
/// receiving a <see cref="TurnToken"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class AggregateTurnMessagesExecutor(string id) : ChatProtocolExecutor(id, declareCrossRunShareable: true), IResettableExecutor
|
internal sealed class CollectChatMessagesExecutor(string id) : ChatProtocolExecutor(id, declareCrossRunShareable: true), IResettableExecutor
|
||||||
{
|
{
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
protected override ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
|
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)
|
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;
|
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))
|
if (this._allowWrapped && this._wrappedRequests.TryGetValue(message.RequestId, out ExternalRequest? originalRequest))
|
||||||
{
|
{
|
||||||
await context.SendMessageAsync(originalRequest.RewrapResponse(message), cancellationToken: cancellationToken).ConfigureAwait(false);
|
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);
|
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);
|
await context.SendMessageAsync(data, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
return message;
|
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;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||||
using Microsoft.Agents.AI.Workflows.Execution;
|
|
||||||
using Microsoft.Shared.Diagnostics;
|
using Microsoft.Shared.Diagnostics;
|
||||||
|
|
||||||
namespace Microsoft.Agents.AI.Workflows;
|
namespace Microsoft.Agents.AI.Workflows;
|
||||||
@@ -189,16 +188,6 @@ public class Workflow
|
|||||||
await this.TryResetExecutorRegistrationsAsync().ConfigureAwait(false);
|
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>
|
/// <summary>
|
||||||
/// Retrieves a <see cref="ProtocolDescriptor"/> defining how to interact with this workflow.
|
/// Retrieves a <see cref="ProtocolDescriptor"/> defining how to interact with this workflow.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -210,8 +199,6 @@ public class Workflow
|
|||||||
ExecutorBinding startExecutorRegistration = this.ExecutorBindings[this.StartExecutorId];
|
ExecutorBinding startExecutorRegistration = this.ExecutorBindings[this.StartExecutorId];
|
||||||
Executor startExecutor = await startExecutorRegistration.CreateInstanceAsync(string.Empty)
|
Executor startExecutor = await startExecutorRegistration.CreateInstanceAsync(string.Empty)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
startExecutor.Configure(new NoOpExternalRequestContext());
|
|
||||||
|
|
||||||
return startExecutor.DescribeProtocol();
|
return startExecutor.DescribeProtocol();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ internal static partial class WorkflowsJsonUtilities
|
|||||||
[JsonSerializable(typeof(TurnToken))]
|
[JsonSerializable(typeof(TurnToken))]
|
||||||
|
|
||||||
// Built-in Executor State Types
|
// Built-in Executor State Types
|
||||||
[JsonSerializable(typeof(AIAgentHostState))]
|
[JsonSerializable(typeof(AIAgentHostExecutor))]
|
||||||
|
|
||||||
// Event Types
|
// Event Types
|
||||||
//[JsonSerializable(typeof(WorkflowEvent))]
|
//[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.
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
|
||||||
using Microsoft.Agents.AI.Workflows.Execution;
|
using Microsoft.Agents.AI.Workflows.Execution;
|
||||||
using Microsoft.Agents.AI.Workflows.Specialized;
|
|
||||||
|
|
||||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||||
|
|
||||||
public class EdgeMapSmokeTests
|
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]
|
[Fact]
|
||||||
public async Task Test_EdgeMap_MaintainsFanInEdgeStateAsync()
|
public async Task Test_EdgeMap_MaintainsFanInEdgeStateAsync()
|
||||||
{
|
{
|
||||||
TestRunContext runContext = new();
|
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 = [];
|
Dictionary<string, HashSet<Edge>> workflowEdges = [];
|
||||||
|
|
||||||
FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0), null);
|
FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0), null);
|
||||||
@@ -97,14 +26,8 @@ public class EdgeMapSmokeTests
|
|||||||
|
|
||||||
workflowEdges["executor1"] = [fanInEdge];
|
workflowEdges["executor1"] = [fanInEdge];
|
||||||
workflowEdges["executor2"] = [fanInEdge];
|
workflowEdges["executor2"] = [fanInEdge];
|
||||||
EdgeMap edgeMap = new(runContext, workflowEdges, [], "executor1", null);
|
|
||||||
|
|
||||||
runContext.ConfigureExecutors(
|
EdgeMap edgeMap = new(runContext, workflowEdges, [], "executor1", null);
|
||||||
[
|
|
||||||
new ForwardMessageExecutor<string>("executor1"),
|
|
||||||
new ForwardMessageExecutor<string>("executor2"),
|
|
||||||
new ForwardMessageExecutor<string>("executor3")
|
|
||||||
], edgeMap);
|
|
||||||
|
|
||||||
DeliveryMapping? mapping = await edgeMap.PrepareDeliveryForEdgeAsync(fanInEdge, new("part1", "executor1"));
|
DeliveryMapping? mapping = await edgeMap.PrepareDeliveryForEdgeAsync(fanInEdge, new("part1", "executor1"));
|
||||||
mapping.Should().BeNull();
|
mapping.Should().BeNull();
|
||||||
|
|||||||
@@ -28,11 +28,9 @@ public class EdgeRunnerTests
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
TestRunContext runContext = new();
|
TestRunContext runContext = new();
|
||||||
runContext.ConfigureExecutors(
|
|
||||||
[
|
runContext.Executors["executor1"] = new ForwardMessageExecutor<string>("executor1");
|
||||||
new ForwardMessageExecutor<string>("executor1"),
|
runContext.Executors["executor2"] = new ForwardMessageExecutor<string>("executor2");
|
||||||
new ForwardMessageExecutor<string>("executor2")
|
|
||||||
]);
|
|
||||||
|
|
||||||
DirectEdgeData edgeData = new("executor1", "executor2", new EdgeId(0), condition);
|
DirectEdgeData edgeData = new("executor1", "executor2", new EdgeId(0), condition);
|
||||||
DirectEdgeRunner runner = new(runContext, edgeData);
|
DirectEdgeRunner runner = new(runContext, edgeData);
|
||||||
@@ -80,11 +78,9 @@ public class EdgeRunnerTests
|
|||||||
{
|
{
|
||||||
TestRunContext runContext = new();
|
TestRunContext runContext = new();
|
||||||
|
|
||||||
runContext.ConfigureExecutors([
|
runContext.Executors["executor1"] = new ForwardMessageExecutor<string>("executor1");
|
||||||
new ForwardMessageExecutor<string>("executor1"),
|
runContext.Executors["executor2"] = new ForwardMessageExecutor<string>("executor2");
|
||||||
new ForwardMessageExecutor<string>("executor2"),
|
runContext.Executors["executor3"] = new ForwardMessageExecutor<string>("executor3");
|
||||||
new ForwardMessageExecutor<string>("executor3")
|
|
||||||
]);
|
|
||||||
|
|
||||||
Func<object?, int, IEnumerable<int>>? assigner
|
Func<object?, int, IEnumerable<int>>? assigner
|
||||||
= assignerSelectsEmpty.HasValue
|
= assignerSelectsEmpty.HasValue
|
||||||
@@ -154,11 +150,10 @@ public class EdgeRunnerTests
|
|||||||
public async Task Test_FanInEdgeRunnerAsync()
|
public async Task Test_FanInEdgeRunnerAsync()
|
||||||
{
|
{
|
||||||
TestRunContext runContext = new();
|
TestRunContext runContext = new();
|
||||||
runContext.ConfigureExecutors([
|
|
||||||
new ForwardMessageExecutor<string>("executor1"),
|
runContext.Executors["executor1"] = new ForwardMessageExecutor<string>("executor1");
|
||||||
new ForwardMessageExecutor<string>("executor2"),
|
runContext.Executors["executor2"] = new ForwardMessageExecutor<string>("executor2");
|
||||||
new ForwardMessageExecutor<string>("executor3")
|
runContext.Executors["executor3"] = new ForwardMessageExecutor<string>("executor3");
|
||||||
]);
|
|
||||||
|
|
||||||
FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0), null);
|
FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0), null);
|
||||||
FanInEdgeRunner runner = new(runContext, edgeData);
|
FanInEdgeRunner runner = new(runContext, edgeData);
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ public class RepresentationTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Test_SpecializedExecutor_InfosAsync()
|
public async Task Test_SpecializedExecutor_InfosAsync()
|
||||||
{
|
{
|
||||||
await RunExecutorBindingInfoMatchTestAsync(new AIAgentHostExecutor(new TestAgent(), new()));
|
await RunExecutorBindingInfoMatchTestAsync(new AIAgentHostExecutor(new TestAgent()));
|
||||||
await RunExecutorBindingInfoMatchTestAsync(new RequestInfoExecutor(TestRequestPort));
|
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;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
@@ -14,6 +17,102 @@ namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
|||||||
|
|
||||||
public class SpecializedExecutorSmokeTests
|
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
|
internal sealed class TestWorkflowContext(string executorId, bool concurrentRunsEnabled = false) : IWorkflowContext
|
||||||
{
|
{
|
||||||
private readonly StateManager _stateManager = new();
|
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."
|
"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);
|
TestAIAgent agent = new(expected);
|
||||||
AIAgentHostExecutor host = new(agent, new());
|
AIAgentHostExecutor host = new(agent);
|
||||||
|
|
||||||
TestWorkflowContext collectingContext = new(host.Id);
|
TestWorkflowContext collectingContext = new(host.Id);
|
||||||
|
|
||||||
@@ -104,8 +203,8 @@ public class SpecializedExecutorSmokeTests
|
|||||||
{
|
{
|
||||||
const string AgentAName = "TestAgentAName";
|
const string AgentAName = "TestAgentAName";
|
||||||
const string AgentBName = "TestAgentBName";
|
const string AgentBName = "TestAgentBName";
|
||||||
TestReplayAgent agentA = new(name: AgentAName);
|
TestAIAgent agentA = new(name: AgentAName);
|
||||||
TestReplayAgent agentB = new(name: AgentBName);
|
TestAIAgent agentB = new(name: AgentBName);
|
||||||
var workflow = new WorkflowBuilder(agentA).AddEdge(agentA, agentB).Build();
|
var workflow = new WorkflowBuilder(agentA).AddEdge(agentA, agentB).Build();
|
||||||
var definition = workflow.ToWorkflowInfo();
|
var definition = workflow.ToWorkflowInfo();
|
||||||
|
|
||||||
@@ -126,8 +225,8 @@ public class SpecializedExecutorSmokeTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Test_AIAgent_ExecutorId_Use_Agent_ID_When_Name_Not_ProvidedAsync()
|
public async Task Test_AIAgent_ExecutorId_Use_Agent_ID_When_Name_Not_ProvidedAsync()
|
||||||
{
|
{
|
||||||
TestReplayAgent agentA = new();
|
TestAIAgent agentA = new();
|
||||||
TestReplayAgent agentB = new();
|
TestAIAgent agentB = new();
|
||||||
var workflow = new WorkflowBuilder(agentA).AddEdge(agentA, agentB).Build();
|
var workflow = new WorkflowBuilder(agentA).AddEdge(agentA, agentB).Build();
|
||||||
var definition = workflow.ToWorkflowInfo();
|
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.
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -11,36 +10,6 @@ namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
|||||||
|
|
||||||
public class TestRunContext : IRunnerContext
|
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(
|
private sealed class BoundContext(
|
||||||
string executorId,
|
string executorId,
|
||||||
TestRunContext runnerContext,
|
TestRunContext runnerContext,
|
||||||
@@ -88,13 +57,13 @@ public class TestRunContext : IRunnerContext
|
|||||||
return default;
|
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);
|
=> new BoundContext(executorId, this, traceContext);
|
||||||
|
|
||||||
public ConcurrentQueue<ExternalRequest> ExternalRequests { get; } = [];
|
public List<ExternalRequest> ExternalRequests { get; } = [];
|
||||||
public ValueTask PostAsync(ExternalRequest request)
|
public ValueTask PostAsync(ExternalRequest request)
|
||||||
{
|
{
|
||||||
this.ExternalRequests.Enqueue(request);
|
this.ExternalRequests.Add(request);
|
||||||
return default;
|
return default;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,8 +99,8 @@ public class TestRunContext : IRunnerContext
|
|||||||
public Dictionary<string, Executor> Executors { get; set; } = [];
|
public Dictionary<string, Executor> Executors { get; set; } = [];
|
||||||
public string StartingExecutorId { get; set; } = string.Empty;
|
public string StartingExecutorId { get; set; } = string.Empty;
|
||||||
|
|
||||||
public bool WithCheckpointing => false;
|
public bool WithCheckpointing => throw new NotSupportedException();
|
||||||
public bool ConcurrentRunsEnabled => false;
|
public bool ConcurrentRunsEnabled => throw new NotSupportedException();
|
||||||
|
|
||||||
ValueTask<Executor> IRunnerContext.EnsureExecutorAsync(string executorId, IStepTracer? tracer, CancellationToken cancellationToken) =>
|
ValueTask<Executor> IRunnerContext.EnsureExecutorAsync(string executorId, IStepTracer? tracer, CancellationToken cancellationToken) =>
|
||||||
new(this.Executors[executorId]);
|
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]
|
## [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
|
## [1.0.0b260116] - 2026-01-16
|
||||||
|
|
||||||
### Added
|
### 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/).
|
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
|
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260116...HEAD
|
||||||
[1.0.0b260123]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260116...python-1.0.0b260123
|
|
||||||
[1.0.0b260116]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260114...python-1.0.0b260116
|
[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.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
|
[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"}]
|
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
version = "1.0.0b260123"
|
version = "1.0.0b260116"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
urls.homepage = "https://aka.ms/agent-framework"
|
urls.homepage = "https://aka.ms/agent-framework"
|
||||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "agent-framework-ag-ui"
|
name = "agent-framework-ag-ui"
|
||||||
version = "1.0.0b260123"
|
version = "1.0.0b260116"
|
||||||
description = "AG-UI protocol integration for Agent Framework"
|
description = "AG-UI protocol integration for Agent Framework"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
|
|||||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
version = "1.0.0b260123"
|
version = "1.0.0b260116"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
urls.homepage = "https://aka.ms/agent-framework"
|
urls.homepage = "https://aka.ms/agent-framework"
|
||||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
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"}]
|
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
version = "1.0.0b260123"
|
version = "1.0.0b260116"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
urls.homepage = "https://aka.ms/agent-framework"
|
urls.homepage = "https://aka.ms/agent-framework"
|
||||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
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"}]
|
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
version = "1.0.0b260123"
|
version = "1.0.0b260116"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
urls.homepage = "https://aka.ms/agent-framework"
|
urls.homepage = "https://aka.ms/agent-framework"
|
||||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
import importlib.metadata
|
import importlib.metadata
|
||||||
|
|
||||||
from ._app import AgentFunctionApp
|
from ._app import AgentFunctionApp
|
||||||
from ._callbacks import AgentCallbackContext, AgentResponseCallbackProtocol
|
|
||||||
from ._orchestration import DurableAIAgent
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
__version__ = importlib.metadata.version(__name__)
|
__version__ = importlib.metadata.version(__name__)
|
||||||
@@ -12,9 +10,6 @@ except importlib.metadata.PackageNotFoundError:
|
|||||||
__version__ = "0.0.0" # Fallback for development mode
|
__version__ = "0.0.0" # Fallback for development mode
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AgentCallbackContext",
|
|
||||||
"AgentFunctionApp",
|
"AgentFunctionApp",
|
||||||
"AgentResponseCallbackProtocol",
|
|
||||||
"DurableAIAgent",
|
|
||||||
"__version__",
|
"__version__",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,16 +8,16 @@ with Azure Durable Entities, enabling stateful and durable AI agent execution.
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import uuid
|
||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Callable, Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import TYPE_CHECKING, Any, TypeVar, cast
|
from typing import TYPE_CHECKING, Any, TypeVar, cast
|
||||||
|
|
||||||
import azure.durable_functions as df
|
import azure.durable_functions as df
|
||||||
import azure.functions as func
|
import azure.functions as func
|
||||||
from agent_framework import AgentProtocol, get_logger
|
from agent_framework import AgentProtocol, get_logger
|
||||||
|
from agent_framework_durabletask import (
|
||||||
from ._callbacks import AgentResponseCallbackProtocol
|
|
||||||
from ._constants import (
|
|
||||||
DEFAULT_MAX_POLL_RETRIES,
|
DEFAULT_MAX_POLL_RETRIES,
|
||||||
DEFAULT_POLL_INTERVAL_SECONDS,
|
DEFAULT_POLL_INTERVAL_SECONDS,
|
||||||
MIMETYPE_APPLICATION_JSON,
|
MIMETYPE_APPLICATION_JSON,
|
||||||
@@ -28,12 +28,17 @@ from ._constants import (
|
|||||||
THREAD_ID_HEADER,
|
THREAD_ID_HEADER,
|
||||||
WAIT_FOR_RESPONSE_FIELD,
|
WAIT_FOR_RESPONSE_FIELD,
|
||||||
WAIT_FOR_RESPONSE_HEADER,
|
WAIT_FOR_RESPONSE_HEADER,
|
||||||
|
AgentResponseCallbackProtocol,
|
||||||
|
AgentSessionId,
|
||||||
|
ApiResponseFields,
|
||||||
|
DurableAgentState,
|
||||||
|
DurableAIAgent,
|
||||||
|
RunRequest,
|
||||||
)
|
)
|
||||||
from ._durable_agent_state import DurableAgentState
|
|
||||||
from ._entities import create_agent_entity
|
from ._entities import create_agent_entity
|
||||||
from ._errors import IncomingRequestError
|
from ._errors import IncomingRequestError
|
||||||
from ._models import AgentSessionId, RunRequest
|
from ._orchestration import AgentOrchestrationContextType, AgentTask, AzureFunctionsAgentExecutor
|
||||||
from ._orchestration import AgentOrchestrationContextType, DurableAIAgent
|
|
||||||
|
|
||||||
logger = get_logger("agent_framework.azurefunctions")
|
logger = get_logger("agent_framework.azurefunctions")
|
||||||
|
|
||||||
@@ -294,7 +299,7 @@ class AgentFunctionApp(DFAppBase):
|
|||||||
self,
|
self,
|
||||||
context: AgentOrchestrationContextType,
|
context: AgentOrchestrationContextType,
|
||||||
agent_name: str,
|
agent_name: str,
|
||||||
) -> DurableAIAgent:
|
) -> DurableAIAgent[AgentTask]:
|
||||||
"""Return a DurableAIAgent proxy for a registered agent.
|
"""Return a DurableAIAgent proxy for a registered agent.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -305,14 +310,15 @@ class AgentFunctionApp(DFAppBase):
|
|||||||
ValueError: If the requested agent has not been registered.
|
ValueError: If the requested agent has not been registered.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
DurableAIAgent wrapper bound to the orchestration context.
|
DurableAIAgent[AgentTask] wrapper bound to the orchestration context.
|
||||||
"""
|
"""
|
||||||
normalized_name = str(agent_name)
|
normalized_name = str(agent_name)
|
||||||
|
|
||||||
if normalized_name not in self._agent_metadata:
|
if normalized_name not in self._agent_metadata:
|
||||||
raise ValueError(f"Agent '{normalized_name}' is not registered with this app.")
|
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(
|
def _setup_agent_functions(
|
||||||
self,
|
self,
|
||||||
@@ -375,8 +381,6 @@ class AgentFunctionApp(DFAppBase):
|
|||||||
"enable_tool_calls": true|false (optional, default: true)
|
"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
|
request_response_format: str = REQUEST_RESPONSE_FORMAT_JSON
|
||||||
thread_id: str | None = None
|
thread_id: str | None = None
|
||||||
|
|
||||||
@@ -385,9 +389,9 @@ class AgentFunctionApp(DFAppBase):
|
|||||||
thread_id = self._resolve_thread_id(req=req, req_body=req_body)
|
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)
|
wait_for_response = self._should_wait_for_response(req=req, req_body=req_body)
|
||||||
|
|
||||||
logger.debug(f"[HTTP Trigger] Message: {message}")
|
logger.debug(
|
||||||
logger.debug(f"[HTTP Trigger] Thread ID: {thread_id}")
|
f"[HTTP Trigger] Message: {message}, Thread ID: {thread_id}, wait_for_response: {wait_for_response}"
|
||||||
logger.debug(f"[HTTP Trigger] wait_for_response: {wait_for_response}")
|
)
|
||||||
|
|
||||||
if not message:
|
if not message:
|
||||||
logger.warning("[HTTP Trigger] Request rejected: Missing 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)
|
session_id = self._create_session_id(agent_name, thread_id)
|
||||||
correlation_id = self._generate_unique_id()
|
correlation_id = self._generate_unique_id()
|
||||||
|
|
||||||
logger.debug(f"[HTTP Trigger] Using session ID: {session_id}")
|
logger.debug(
|
||||||
logger.debug(f"[HTTP Trigger] Generated correlation ID: {correlation_id}")
|
f"[HTTP Trigger] Calling entity to run agent using session ID: {session_id} "
|
||||||
logger.debug("[HTTP Trigger] Calling entity to run agent...")
|
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(
|
run_request = self._build_request_data(
|
||||||
req_body,
|
req_body,
|
||||||
message,
|
message,
|
||||||
thread_id,
|
|
||||||
correlation_id,
|
correlation_id,
|
||||||
request_response_format,
|
request_response_format,
|
||||||
)
|
)
|
||||||
@@ -622,14 +629,16 @@ class AgentFunctionApp(DFAppBase):
|
|||||||
session_id = AgentSessionId.with_random_key(agent_name)
|
session_id = AgentSessionId.with_random_key(agent_name)
|
||||||
|
|
||||||
# Build entity instance ID
|
# 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
|
# Create run request
|
||||||
correlation_id = self._generate_unique_id()
|
correlation_id = self._generate_unique_id()
|
||||||
run_request = self._build_request_data(
|
run_request = self._build_request_data(
|
||||||
req_body={"message": query, "role": "user"},
|
req_body={"message": query, "role": "user"},
|
||||||
message=query,
|
message=query,
|
||||||
thread_id=str(session_id),
|
|
||||||
correlation_id=correlation_id,
|
correlation_id=correlation_id,
|
||||||
request_response_format=REQUEST_RESPONSE_FORMAT_TEXT,
|
request_response_format=REQUEST_RESPONSE_FORMAT_TEXT,
|
||||||
)
|
)
|
||||||
@@ -781,7 +790,7 @@ class AgentFunctionApp(DFAppBase):
|
|||||||
agent_response = state.try_get_agent_response(correlation_id)
|
agent_response = state.try_get_agent_response(correlation_id)
|
||||||
if agent_response:
|
if agent_response:
|
||||||
result = self._build_success_result(
|
result = self._build_success_result(
|
||||||
response_data=agent_response,
|
response_message=agent_response.text,
|
||||||
message=message,
|
message=message,
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
correlation_id=correlation_id,
|
correlation_id=correlation_id,
|
||||||
@@ -827,23 +836,22 @@ class AgentFunctionApp(DFAppBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _build_success_result(
|
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]:
|
) -> dict[str, Any]:
|
||||||
"""Build the success result returned to the HTTP caller."""
|
"""Build the success result returned to the HTTP caller."""
|
||||||
return self._build_response_payload(
|
return self._build_response_payload(
|
||||||
response=response_data.get("content"),
|
response=response_message,
|
||||||
message=message,
|
message=message,
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
status="success",
|
status="success",
|
||||||
correlation_id=correlation_id,
|
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(
|
def _build_request_data(
|
||||||
self,
|
self,
|
||||||
req_body: dict[str, Any],
|
req_body: dict[str, Any],
|
||||||
message: str,
|
message: str,
|
||||||
thread_id: str,
|
|
||||||
correlation_id: str,
|
correlation_id: str,
|
||||||
request_response_format: str,
|
request_response_format: str,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -857,8 +865,8 @@ class AgentFunctionApp(DFAppBase):
|
|||||||
request_response_format=request_response_format,
|
request_response_format=request_response_format,
|
||||||
response_format=req_body.get("response_format"),
|
response_format=req_body.get("response_format"),
|
||||||
enable_tool_calls=enable_tool_calls,
|
enable_tool_calls=enable_tool_calls,
|
||||||
thread_id=thread_id,
|
|
||||||
correlation_id=correlation_id,
|
correlation_id=correlation_id,
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
).to_dict()
|
).to_dict()
|
||||||
|
|
||||||
def _build_accepted_response(self, message: str, thread_id: str, correlation_id: str) -> dict[str, Any]:
|
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:
|
def _generate_unique_id(self) -> str:
|
||||||
"""Generate a new unique identifier."""
|
"""Generate a new unique identifier."""
|
||||||
import uuid
|
|
||||||
|
|
||||||
return uuid.uuid4().hex
|
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."""
|
"""Create a session identifier using the provided thread id or a random value."""
|
||||||
if thread_id:
|
if thread_id:
|
||||||
return AgentSessionId(name=func_name, key=thread_id)
|
return AgentSessionId(name=agent_name, key=thread_id)
|
||||||
return AgentSessionId.with_random_key(name=func_name)
|
return AgentSessionId.with_random_key(name=agent_name)
|
||||||
|
|
||||||
def _resolve_thread_id(self, req: func.HttpRequest, req_body: dict[str, Any]) -> str:
|
def _resolve_thread_id(self, req: func.HttpRequest, req_body: dict[str, Any]) -> str:
|
||||||
"""Retrieve the thread identifier from request body or query parameters."""
|
"""Retrieve the thread identifier from request body or query parameters."""
|
||||||
|
|||||||
@@ -8,346 +8,41 @@ allows for long-running agent conversations.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import inspect
|
from collections.abc import Callable
|
||||||
from collections.abc import AsyncIterable, Callable
|
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
import azure.durable_functions as df
|
import azure.durable_functions as df
|
||||||
from agent_framework import (
|
from agent_framework import AgentProtocol, get_logger
|
||||||
AgentProtocol,
|
from agent_framework_durabletask import (
|
||||||
AgentResponse,
|
AgentEntity,
|
||||||
AgentResponseUpdate,
|
AgentEntityStateProviderMixin,
|
||||||
ChatMessage,
|
AgentResponseCallbackProtocol,
|
||||||
Content,
|
|
||||||
Role,
|
|
||||||
get_logger,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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")
|
logger = get_logger("agent_framework.azurefunctions.entities")
|
||||||
|
|
||||||
|
|
||||||
class AgentEntity:
|
class AzureFunctionEntityStateProvider(AgentEntityStateProviderMixin):
|
||||||
"""Durable entity that manages agent execution and conversation state.
|
"""Azure Functions Durable Entity state provider for AgentEntity.
|
||||||
|
|
||||||
This entity:
|
This class utilizes the Durable Entity context from `azure-functions-durable` package
|
||||||
- Maintains conversation history
|
to get and set the state of the agent entity.
|
||||||
- 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
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
agent: AgentProtocol
|
def __init__(self, context: df.DurableEntityContext) -> None:
|
||||||
state: DurableAgentState
|
self._context = context
|
||||||
|
|
||||||
def __init__(
|
def _get_state_dict(self) -> dict[str, Any]:
|
||||||
self,
|
raw_state = self._context.get_state(lambda: {})
|
||||||
agent: AgentProtocol,
|
if not isinstance(raw_state, dict):
|
||||||
callback: AgentResponseCallbackProtocol | None = None,
|
return {}
|
||||||
):
|
return cast(dict[str, Any], raw_state)
|
||||||
"""Initialize the agent entity.
|
|
||||||
|
|
||||||
Args:
|
def _set_state_dict(self, state: dict[str, Any]) -> None:
|
||||||
agent: The Microsoft Agent Framework agent instance (must implement AgentProtocol)
|
self._context.set_state(state)
|
||||||
callback: Optional callback invoked during streaming updates and final responses
|
|
||||||
"""
|
|
||||||
self.agent = agent
|
|
||||||
self.state = DurableAgentState()
|
|
||||||
self.callback = callback
|
|
||||||
|
|
||||||
logger.debug(f"[AgentEntity] Initialized with agent type: {type(agent).__name__}")
|
def _get_thread_id_from_entity(self) -> str:
|
||||||
|
return self._context.entity_key
|
||||||
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 create_agent_entity(
|
def create_agent_entity(
|
||||||
@@ -368,19 +63,10 @@ def create_agent_entity(
|
|||||||
"""Async handler that executes the entity operations."""
|
"""Async handler that executes the entity operations."""
|
||||||
try:
|
try:
|
||||||
logger.debug("[entity_function] Entity triggered")
|
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)
|
state_provider = AzureFunctionEntityStateProvider(context)
|
||||||
logger.debug("Retrieved state: %s", str(current_state)[:100])
|
entity = AgentEntity(agent, callback, state_provider=state_provider)
|
||||||
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")
|
|
||||||
|
|
||||||
operation = context.operation_name
|
operation = context.operation_name
|
||||||
|
|
||||||
@@ -394,21 +80,18 @@ def create_agent_entity(
|
|||||||
# Fall back to treating input as message string
|
# Fall back to treating input as message string
|
||||||
request = "" if input_data is None else str(cast(object, input_data))
|
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())
|
context.set_result(result.to_dict())
|
||||||
|
|
||||||
elif operation == "reset":
|
elif operation == "reset":
|
||||||
entity.reset(context)
|
entity.reset()
|
||||||
context.set_result({"status": "reset"})
|
context.set_result({"status": "reset"})
|
||||||
|
|
||||||
else:
|
else:
|
||||||
logger.error("[entity_function] Unknown operation: %s", operation)
|
logger.error("[entity_function] Unknown operation: %s", operation)
|
||||||
context.set_result({"error": f"Unknown operation: {operation}"})
|
context.set_result({"error": f"Unknown operation: {operation}"})
|
||||||
|
|
||||||
serialized_state = entity.state.to_dict()
|
logger.info("[entity_function] Operation %s completed successfully", operation)
|
||||||
logger.debug("State dict: %s", serialized_state)
|
|
||||||
context.set_state(serialized_state)
|
|
||||||
logger.info(f"[entity_function] Operation {operation} completed successfully")
|
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("[entity_function] Error executing entity operation %s", 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.
|
This module provides support for using agents inside Durable Function orchestrations.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
from collections.abc import Callable
|
||||||
from collections.abc import AsyncIterator, Callable, Sequence
|
from typing import TYPE_CHECKING, Any, TypeAlias
|
||||||
from typing import TYPE_CHECKING, Any, TypeAlias, cast
|
|
||||||
|
|
||||||
from agent_framework import (
|
import azure.durable_functions as df
|
||||||
AgentProtocol,
|
from agent_framework import AgentThread, get_logger
|
||||||
AgentResponse,
|
from agent_framework_durabletask import (
|
||||||
AgentResponseUpdate,
|
DurableAgentExecutor,
|
||||||
AgentThread,
|
RunRequest,
|
||||||
ChatMessage,
|
ensure_response_format,
|
||||||
get_logger,
|
load_agent_response,
|
||||||
)
|
)
|
||||||
from azure.durable_functions.models import TaskBase
|
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 azure.durable_functions.models.Task import CompoundTask, TaskState
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from ._models import AgentSessionId, DurableAgentThread, RunRequest
|
|
||||||
|
|
||||||
logger = get_logger("agent_framework.azurefunctions.orchestration")
|
logger = get_logger("agent_framework.azurefunctions.orchestration")
|
||||||
|
|
||||||
CompoundActionConstructor: TypeAlias = Callable[[list[Any]], Any] | None
|
CompoundActionConstructor: TypeAlias = Callable[[list[Any]], Any] | None
|
||||||
@@ -45,6 +43,25 @@ else:
|
|||||||
_TypedCompoundTask = CompoundTask
|
_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):
|
class AgentTask(_TypedCompoundTask):
|
||||||
"""A custom Task that wraps entity calls and provides typed AgentResponse results.
|
"""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
|
response_format: Optional Pydantic model for response parsing
|
||||||
correlation_id: Correlation ID for logging
|
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._response_format = response_format
|
||||||
self._correlation_id = correlation_id
|
self._correlation_id = correlation_id
|
||||||
|
|
||||||
|
super().__init__([entity_task])
|
||||||
|
|
||||||
# Override action_repr to expose the inner task's action directly
|
# Override action_repr to expose the inner task's action directly
|
||||||
# This ensures compatibility with ReplaySchema V3 which expects Action objects.
|
# This ensures compatibility with ReplaySchema V3 which expects Action objects.
|
||||||
self.action_repr = entity_task.action_repr
|
self.action_repr = entity_task.action_repr
|
||||||
@@ -95,10 +115,10 @@ class AgentTask(_TypedCompoundTask):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = self._load_agent_response(raw_result)
|
response = load_agent_response(raw_result)
|
||||||
|
|
||||||
if self._response_format is not None:
|
if self._response_format is not None:
|
||||||
self._ensure_response_format(
|
ensure_response_format(
|
||||||
self._response_format,
|
self._response_format,
|
||||||
self._correlation_id,
|
self._correlation_id,
|
||||||
response,
|
response,
|
||||||
@@ -118,230 +138,84 @@ class AgentTask(_TypedCompoundTask):
|
|||||||
self._first_error = child.result
|
self._first_error = child.result
|
||||||
self.set_value(is_error=True, value=self._first_error)
|
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):
|
def __init__(self, context: AgentOrchestrationContextType):
|
||||||
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)
|
|
||||||
"""
|
|
||||||
self.context = context
|
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.
|
def generate_unique_id(self) -> str:
|
||||||
# This is an intentional deviation from AgentProtocol which defines run() as async.
|
return str(self.context.new_uuid())
|
||||||
# The AgentTask can be yielded in Durable Functions orchestrations and will provide
|
|
||||||
# a typed AgentResponse result.
|
def get_run_request(
|
||||||
def run( # type: ignore[override]
|
|
||||||
self,
|
self,
|
||||||
messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
|
message: str,
|
||||||
*,
|
*,
|
||||||
thread: AgentThread | None = None,
|
|
||||||
options: dict[str, Any] | None = None,
|
options: dict[str, Any] | None = None,
|
||||||
**kwargs: Any,
|
) -> RunRequest:
|
||||||
) -> AgentTask:
|
"""Get the current run request from the orchestration context.
|
||||||
"""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.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
messages: The message(s) to send to the agent
|
message: The message to send to the agent
|
||||||
thread: Optional agent thread for conversation context
|
options: Optional options dictionary. Supported keys include
|
||||||
options: Optional dict containing chat options like response_format, tools, etc.
|
``response_format``, ``enable_tool_calls``, and ``wait_for_response``.
|
||||||
**kwargs: Additional arguments (enable_tool_calls)
|
Additional keys are forwarded to the agent execution.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
An AgentTask that resolves to an AgentResponse when yielded
|
RunRequest: The current run request
|
||||||
|
|
||||||
Example:
|
Raises:
|
||||||
@app.orchestration_trigger(context_name="context")
|
ValueError: If wait_for_response=False (not supported in orchestrations)
|
||||||
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
|
|
||||||
"""
|
"""
|
||||||
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)
|
request = super().get_run_request(message, options=options)
|
||||||
opts = options or {}
|
request.orchestration_id = self.context.instance_id
|
||||||
response_format: type[BaseModel] | None = opts.get("response_format")
|
return request
|
||||||
enable_tool_calls = opts.get("enable_tool_calls", kwargs.get("enable_tool_calls", True))
|
|
||||||
|
|
||||||
# Get the session ID for the entity
|
def run_durable_agent(
|
||||||
if isinstance(thread, DurableAgentThread) and thread.session_id is not None:
|
self,
|
||||||
session_id = thread.session_id
|
agent_name: str,
|
||||||
else:
|
run_request: RunRequest,
|
||||||
# Create a unique session ID for each call when no thread is provided
|
thread: AgentThread | None = None,
|
||||||
# This ensures each call gets its own conversation context
|
) -> AgentTask:
|
||||||
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)
|
|
||||||
|
|
||||||
# Create entity ID from session ID
|
# Resolve session
|
||||||
entity_id = session_id.to_entity_id()
|
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(
|
logger.debug(
|
||||||
"[DurableAIAgent] Using correlation_id: %s for entity_id: %s for session_id: %s",
|
"[AzureFunctionsAgentProvider] correlation_id: %s entity_id: %s session_id: %s",
|
||||||
correlation_id,
|
run_request.correlation_id,
|
||||||
entity_id,
|
entity_id,
|
||||||
session_id,
|
session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Prepare the request using RunRequest model
|
# Branch based on wait_for_response
|
||||||
# Include the orchestration's instance_id so it can be stored in the agent's entity state
|
if not run_request.wait_for_response:
|
||||||
run_request = RunRequest(
|
# Fire-and-forget mode: signal entity and return pre-completed task
|
||||||
message=message_str,
|
logger.debug(
|
||||||
enable_tool_calls=enable_tool_calls,
|
"[AzureFunctionsAgentExecutor] Fire-and-forget mode: signaling entity (correlation: %s)",
|
||||||
correlation_id=correlation_id,
|
run_request.correlation_id,
|
||||||
thread_id=session_id.key,
|
|
||||||
response_format=response_format,
|
|
||||||
orchestration_id=self.context.instance_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
|
# 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())
|
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
|
return AgentTask(
|
||||||
agent_task = AgentTask(
|
|
||||||
entity_task=entity_task,
|
entity_task=entity_task,
|
||||||
response_format=response_format,
|
response_format=run_request.response_format,
|
||||||
correlation_id=correlation_id,
|
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"}]
|
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
version = "1.0.0b260123"
|
version = "1.0.0b260116"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
urls.homepage = "https://aka.ms/agent-framework"
|
urls.homepage = "https://aka.ms/agent-framework"
|
||||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||||
@@ -23,6 +23,7 @@ classifiers = [
|
|||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-framework-core",
|
"agent-framework-core",
|
||||||
|
"agent-framework-durabletask",
|
||||||
"azure-functions",
|
"azure-functions",
|
||||||
"azure-functions-durable",
|
"azure-functions-durable",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -15,10 +15,9 @@ Usage:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from agent_framework_durabletask import THREAD_ID_HEADER
|
||||||
from testutils import SampleTestHelper, skip_if_azure_functions_integration_tests_disabled
|
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
|
# Module-level markers - applied to all tests in this file
|
||||||
pytestmark = [
|
pytestmark = [
|
||||||
pytest.mark.sample("01_single_agent"),
|
pytest.mark.sample("01_single_agent"),
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
"""Unit tests for AgentFunctionApp."""
|
"""Unit tests for AgentFunctionApp."""
|
||||||
|
|
||||||
|
# pyright: reportPrivateUsage=false
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import Any, TypeVar
|
from typing import Any, TypeVar
|
||||||
@@ -11,20 +13,42 @@ import azure.durable_functions as df
|
|||||||
import azure.functions as func
|
import azure.functions as func
|
||||||
import pytest
|
import pytest
|
||||||
from agent_framework import AgentResponse, ChatMessage
|
from agent_framework import AgentResponse, ChatMessage
|
||||||
|
from agent_framework_durabletask import (
|
||||||
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 (
|
|
||||||
MIMETYPE_APPLICATION_JSON,
|
MIMETYPE_APPLICATION_JSON,
|
||||||
MIMETYPE_TEXT_PLAIN,
|
MIMETYPE_TEXT_PLAIN,
|
||||||
THREAD_ID_HEADER,
|
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])
|
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:
|
class TestAgentFunctionAppInit:
|
||||||
"""Test suite for AgentFunctionApp initialization."""
|
"""Test suite for AgentFunctionApp initialization."""
|
||||||
|
|
||||||
@@ -88,7 +112,7 @@ class TestAgentFunctionAppInit:
|
|||||||
app.add_agent(mock_agent, callback=specific_callback)
|
app.add_agent(mock_agent, callback=specific_callback)
|
||||||
|
|
||||||
setup_mock.assert_called_once()
|
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 passed_callback is specific_callback
|
||||||
assert enable_http_endpoint is True
|
assert enable_http_endpoint is True
|
||||||
|
|
||||||
@@ -104,7 +128,7 @@ class TestAgentFunctionAppInit:
|
|||||||
app.add_agent(mock_agent)
|
app.add_agent(mock_agent)
|
||||||
|
|
||||||
setup_mock.assert_called_once()
|
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 passed_callback is default_callback
|
||||||
assert enable_http_endpoint is True
|
assert enable_http_endpoint is True
|
||||||
|
|
||||||
@@ -119,7 +143,7 @@ class TestAgentFunctionAppInit:
|
|||||||
AgentFunctionApp(agents=[mock_agent], default_callback=default_callback)
|
AgentFunctionApp(agents=[mock_agent], default_callback=default_callback)
|
||||||
|
|
||||||
setup_mock.assert_called_once()
|
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 passed_callback is default_callback
|
||||||
assert enable_http_endpoint is True
|
assert enable_http_endpoint is True
|
||||||
|
|
||||||
@@ -335,13 +359,12 @@ class TestAgentEntityOperations:
|
|||||||
return_value=AgentResponse(messages=[ChatMessage(role="assistant", text="Test response")])
|
return_value=AgentResponse(messages=[ChatMessage(role="assistant", text="Test response")])
|
||||||
)
|
)
|
||||||
|
|
||||||
entity = AgentEntity(mock_agent)
|
entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="test-conv-123"))
|
||||||
mock_context = Mock()
|
|
||||||
|
|
||||||
result = await entity.run(
|
result = await entity.run({
|
||||||
mock_context,
|
"message": "Test message",
|
||||||
{"message": "Test message", "thread_id": "test-conv-123", "correlationId": "corr-app-entity-1"},
|
"correlationId": "corr-app-entity-1",
|
||||||
)
|
})
|
||||||
|
|
||||||
assert isinstance(result, AgentResponse)
|
assert isinstance(result, AgentResponse)
|
||||||
assert result.text == "Test response"
|
assert result.text == "Test response"
|
||||||
@@ -354,22 +377,17 @@ class TestAgentEntityOperations:
|
|||||||
return_value=AgentResponse(messages=[ChatMessage(role="assistant", text="Response 1")])
|
return_value=AgentResponse(messages=[ChatMessage(role="assistant", text="Response 1")])
|
||||||
)
|
)
|
||||||
|
|
||||||
entity = AgentEntity(mock_agent)
|
entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="conv-1"))
|
||||||
mock_context = Mock()
|
|
||||||
|
|
||||||
# Send first message
|
# Send first message
|
||||||
await entity.run(
|
await entity.run({"message": "Message 1", "correlationId": "corr-app-entity-2"})
|
||||||
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-app-entity-2"}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Each conversation turn creates 2 entries: request and response
|
# Each conversation turn creates 2 entries: request and response
|
||||||
history = entity.state.data.conversation_history[0].messages # Request entry
|
history = entity.state.data.conversation_history[0].messages # Request entry
|
||||||
assert len(history) == 1 # Just the user message
|
assert len(history) == 1 # Just the user message
|
||||||
|
|
||||||
# Send second message
|
# Send second message
|
||||||
await entity.run(
|
await entity.run({"message": "Message 2", "correlationId": "corr-app-entity-2b"})
|
||||||
mock_context, {"message": "Message 2", "thread_id": "conv-2", "correlationId": "corr-app-entity-2b"}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Now we have 4 entries total (2 requests + 2 responses)
|
# Now we have 4 entries total (2 requests + 2 responses)
|
||||||
# Access the first request entry
|
# Access the first request entry
|
||||||
@@ -393,32 +411,26 @@ class TestAgentEntityOperations:
|
|||||||
return_value=AgentResponse(messages=[ChatMessage(role="assistant", text="Response")])
|
return_value=AgentResponse(messages=[ChatMessage(role="assistant", text="Response")])
|
||||||
)
|
)
|
||||||
|
|
||||||
entity = AgentEntity(mock_agent)
|
entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="conv-1"))
|
||||||
mock_context = Mock()
|
|
||||||
|
|
||||||
assert len(entity.state.data.conversation_history) == 0
|
assert len(entity.state.data.conversation_history) == 0
|
||||||
|
|
||||||
await entity.run(
|
await entity.run({"message": "Message 1", "correlationId": "corr-app-entity-3a"})
|
||||||
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-app-entity-3a"}
|
|
||||||
)
|
|
||||||
assert len(entity.state.data.conversation_history) == 2
|
assert len(entity.state.data.conversation_history) == 2
|
||||||
|
|
||||||
await entity.run(
|
await entity.run({"message": "Message 2", "correlationId": "corr-app-entity-3b"})
|
||||||
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-app-entity-3b"}
|
|
||||||
)
|
|
||||||
assert len(entity.state.data.conversation_history) == 4
|
assert len(entity.state.data.conversation_history) == 4
|
||||||
|
|
||||||
def test_entity_reset(self) -> None:
|
def test_entity_reset(self) -> None:
|
||||||
"""Test that entity reset clears state."""
|
"""Test that entity reset clears state."""
|
||||||
mock_agent = Mock()
|
mock_agent = Mock()
|
||||||
entity = AgentEntity(mock_agent)
|
entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider())
|
||||||
|
|
||||||
# Set some state
|
# Set some state
|
||||||
entity.state = DurableAgentState()
|
entity.state = DurableAgentState()
|
||||||
|
|
||||||
# Reset
|
# Reset
|
||||||
mock_context = Mock()
|
entity.reset()
|
||||||
entity.reset(mock_context)
|
|
||||||
|
|
||||||
assert len(entity.state.data.conversation_history) == 0
|
assert len(entity.state.data.conversation_history) == 0
|
||||||
|
|
||||||
@@ -447,7 +459,6 @@ class TestAgentEntityFactory:
|
|||||||
mock_context.operation_name = "run"
|
mock_context.operation_name = "run"
|
||||||
mock_context.get_input.return_value = {
|
mock_context.get_input.return_value = {
|
||||||
"message": "Test message",
|
"message": "Test message",
|
||||||
"thread_id": "conv-123",
|
|
||||||
"correlationId": "corr-app-factory-1",
|
"correlationId": "corr-app-factory-1",
|
||||||
}
|
}
|
||||||
mock_context.get_state.return_value = None
|
mock_context.get_state.return_value = None
|
||||||
@@ -475,7 +486,6 @@ class TestAgentEntityFactory:
|
|||||||
mock_context.operation_name = "run_agent"
|
mock_context.operation_name = "run_agent"
|
||||||
mock_context.get_input.return_value = {
|
mock_context.get_input.return_value = {
|
||||||
"message": "Test message",
|
"message": "Test message",
|
||||||
"thread_id": "conv-123",
|
|
||||||
"correlationId": "corr-app-factory-1",
|
"correlationId": "corr-app-factory-1",
|
||||||
}
|
}
|
||||||
mock_context.get_state.return_value = None
|
mock_context.get_state.return_value = None
|
||||||
@@ -595,7 +605,11 @@ class TestAgentEntityFactory:
|
|||||||
}
|
}
|
||||||
|
|
||||||
mock_context = Mock()
|
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
|
mock_context.get_state.return_value = existing_state
|
||||||
|
|
||||||
with patch.object(DurableAgentState, "from_dict", wraps=DurableAgentState.from_dict) as from_dict_mock:
|
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 = Mock()
|
||||||
mock_agent.run = AsyncMock(side_effect=Exception("Agent error"))
|
mock_agent.run = AsyncMock(side_effect=Exception("Agent error"))
|
||||||
|
|
||||||
entity = AgentEntity(mock_agent)
|
entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="conv-1"))
|
||||||
mock_context = Mock()
|
|
||||||
|
|
||||||
result = await entity.run(
|
result = await entity.run({
|
||||||
mock_context, {"message": "Test message", "thread_id": "conv-1", "correlationId": "corr-app-error-1"}
|
"message": "Test message",
|
||||||
)
|
"correlationId": "corr-app-error-1",
|
||||||
|
})
|
||||||
|
|
||||||
assert isinstance(result, AgentResponse)
|
assert isinstance(result, AgentResponse)
|
||||||
assert len(result.messages) == 1
|
assert len(result.messages) == 1
|
||||||
@@ -636,6 +650,7 @@ class TestErrorHandling:
|
|||||||
|
|
||||||
mock_context = Mock()
|
mock_context = Mock()
|
||||||
mock_context.operation_name = "run"
|
mock_context.operation_name = "run"
|
||||||
|
mock_context.operation_name = "run"
|
||||||
mock_context.get_input.side_effect = Exception("Input error")
|
mock_context.get_input.side_effect = Exception("Input error")
|
||||||
mock_context.get_state.return_value = None
|
mock_context.get_state.return_value = None
|
||||||
|
|
||||||
@@ -710,7 +725,7 @@ class TestIncomingRequestParsing:
|
|||||||
|
|
||||||
request = Mock()
|
request = Mock()
|
||||||
request.params = {"thread_id": "query-thread"}
|
request.params = {"thread_id": "query-thread"}
|
||||||
req_body = {}
|
req_body: dict[str, Any] = {}
|
||||||
|
|
||||||
thread_id = app._resolve_thread_id(request, req_body)
|
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["message"] == "Plain text via HTTP"
|
||||||
assert run_request["role"] == "user"
|
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:
|
async def test_http_run_accept_header_returns_json(self) -> None:
|
||||||
"""Test that Accept header requesting JSON results in JSON response."""
|
"""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,
|
patch.object(app, "durable_client_input") as client_mock,
|
||||||
):
|
):
|
||||||
# Setup mock decorator chain
|
# Setup mock decorator chain
|
||||||
func_name_mock.return_value = lambda f: f
|
func_name_mock.return_value = _identity_decorator
|
||||||
mcp_trigger_mock.return_value = lambda f: f
|
mcp_trigger_mock.return_value = _identity_decorator
|
||||||
client_mock.return_value = lambda f: f
|
client_mock.return_value = _identity_decorator
|
||||||
|
|
||||||
app._setup_mcp_tool_trigger(mock_agent.name, mock_agent.description)
|
app._setup_mcp_tool_trigger(mock_agent.name, mock_agent.description)
|
||||||
|
|
||||||
@@ -938,11 +953,11 @@ class TestMCPToolEndpoint:
|
|||||||
app = AgentFunctionApp()
|
app = AgentFunctionApp()
|
||||||
|
|
||||||
with (
|
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, "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)
|
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)
|
app = AgentFunctionApp(agents=[mock_agent], enable_mcp_tool_trigger=True)
|
||||||
|
|
||||||
# Capture the health check handler function
|
# Capture the health check handler function
|
||||||
captured_handler = None
|
captured_handler: Callable[[func.HttpRequest], func.HttpResponse] | None = None
|
||||||
|
|
||||||
def capture_decorator(*args, **kwargs):
|
def capture_decorator(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]:
|
||||||
def decorator(func):
|
def decorator(func: TFunc) -> TFunc:
|
||||||
nonlocal captured_handler
|
nonlocal captured_handler
|
||||||
captured_handler = func
|
captured_handler = func
|
||||||
return func
|
return func
|
||||||
|
|||||||
@@ -1,423 +1,32 @@
|
|||||||
# Copyright (c) Microsoft. All rights reserved.
|
# 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
|
Run with: pytest tests/test_entities.py -v
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
from collections.abc import Callable
|
||||||
from collections.abc import AsyncIterator, Callable
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any, TypeVar
|
from typing import Any, TypeVar
|
||||||
from unittest.mock import AsyncMock, Mock, patch
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from agent_framework import AgentResponse, AgentResponseUpdate, ChatMessage, Role
|
from agent_framework import AgentResponse, ChatMessage, Role
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from agent_framework_azurefunctions._durable_agent_state import (
|
from agent_framework_azurefunctions._entities import create_agent_entity
|
||||||
DurableAgentState,
|
|
||||||
DurableAgentStateData,
|
|
||||||
DurableAgentStateMessage,
|
|
||||||
DurableAgentStateRequest,
|
|
||||||
DurableAgentStateTextContent,
|
|
||||||
)
|
|
||||||
from agent_framework_azurefunctions._entities import AgentEntity, create_agent_entity
|
|
||||||
from agent_framework_azurefunctions._models import RunRequest
|
|
||||||
|
|
||||||
TFunc = TypeVar("TFunc", bound=Callable[..., Any])
|
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:
|
def _agent_response(text: str | None) -> AgentResponse:
|
||||||
"""Create an AgentResponse with a single assistant message."""
|
"""Create an AgentResponse with a single assistant message."""
|
||||||
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])
|
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:
|
class TestCreateAgentEntity:
|
||||||
"""Test suite for the create_agent_entity factory function."""
|
"""Test suite for the create_agent_entity factory function."""
|
||||||
|
|
||||||
@@ -439,9 +48,9 @@ class TestCreateAgentEntity:
|
|||||||
# Mock context
|
# Mock context
|
||||||
mock_context = Mock()
|
mock_context = Mock()
|
||||||
mock_context.operation_name = "run"
|
mock_context.operation_name = "run"
|
||||||
|
mock_context.entity_key = "conv-123"
|
||||||
mock_context.get_input.return_value = {
|
mock_context.get_input.return_value = {
|
||||||
"message": "Test message",
|
"message": "Test message",
|
||||||
"thread_id": "conv-123",
|
|
||||||
"correlationId": "corr-entity-factory",
|
"correlationId": "corr-entity-factory",
|
||||||
}
|
}
|
||||||
mock_context.get_state.return_value = None
|
mock_context.get_state.return_value = None
|
||||||
@@ -535,7 +144,7 @@ class TestCreateAgentEntity:
|
|||||||
assert state["data"] == {"conversationHistory": []}
|
assert state["data"] == {"conversationHistory": []}
|
||||||
|
|
||||||
def test_entity_function_restores_existing_state(self) -> None:
|
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()
|
mock_agent = Mock()
|
||||||
|
|
||||||
entity_function = create_agent_entity(mock_agent)
|
entity_function = create_agent_entity(mock_agent)
|
||||||
@@ -584,482 +193,14 @@ class TestCreateAgentEntity:
|
|||||||
mock_context.operation_name = "reset"
|
mock_context.operation_name = "reset"
|
||||||
mock_context.get_state.return_value = existing_state
|
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)
|
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
|
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:
|
# Reset should clear history and persist via set_state
|
||||||
"""Test that run_agent preserves message information on error."""
|
assert mock_context.set_state.called
|
||||||
mock_agent = Mock()
|
persisted_state = mock_context.set_state.call_args[0][0]
|
||||||
mock_agent.run = AsyncMock(side_effect=Exception("Error"))
|
assert persisted_state["data"]["conversationHistory"] == []
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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
|
from unittest.mock import Mock
|
||||||
|
|
||||||
import pytest
|
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 azure.durable_functions.models.Task import TaskBase, TaskState
|
||||||
|
|
||||||
from agent_framework_azurefunctions import AgentFunctionApp, DurableAIAgent
|
from agent_framework_azurefunctions import AgentFunctionApp
|
||||||
from agent_framework_azurefunctions._models import AgentSessionId, DurableAgentThread
|
|
||||||
from agent_framework_azurefunctions._orchestration import AgentTask
|
from agent_framework_azurefunctions._orchestration import AgentTask
|
||||||
|
|
||||||
|
|
||||||
@@ -38,46 +38,96 @@ def _create_entity_task(task_id: int = 1) -> TaskBase:
|
|||||||
return _FakeTask(task_id)
|
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:
|
class TestAgentResponseHelpers:
|
||||||
"""Tests for helper utilities that prepare AgentResponse values."""
|
"""Tests for response handling through public AgentTask API."""
|
||||||
|
|
||||||
@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]
|
|
||||||
|
|
||||||
def test_try_set_value_success(self) -> None:
|
def test_try_set_value_success(self) -> None:
|
||||||
"""Test try_set_value correctly processes successful task completion."""
|
"""Test try_set_value correctly processes successful task completion."""
|
||||||
@@ -142,334 +192,10 @@ class TestAgentResponseHelpers:
|
|||||||
assert isinstance(task.result.value, TestSchema)
|
assert isinstance(task.result.value, TestSchema)
|
||||||
assert task.result.value.answer == "42"
|
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:
|
class TestAgentFunctionAppGetAgent:
|
||||||
"""Test suite for AgentFunctionApp.get_agent."""
|
"""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:
|
def test_get_agent_raises_for_unregistered_agent(self) -> None:
|
||||||
"""Test get_agent raises ValueError when agent is not registered."""
|
"""Test get_agent raises ValueError when agent is not registered."""
|
||||||
app = _app_with_registered_agents("KnownAgent")
|
app = _app_with_registered_agents("KnownAgent")
|
||||||
@@ -478,18 +204,87 @@ class TestAgentFunctionAppGetAgent:
|
|||||||
app.get_agent(Mock(), "MissingAgent")
|
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:
|
class TestOrchestrationIntegration:
|
||||||
"""Integration tests for orchestration scenarios."""
|
"""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."""
|
"""Simulate sequential agent calls in an orchestration."""
|
||||||
mock_context = Mock()
|
executor, context, uuid_hexes = executor_with_multiple_uuids
|
||||||
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"])
|
|
||||||
|
|
||||||
# Track entity calls
|
# Track entity calls
|
||||||
entity_calls: list[dict[str, Any]] = []
|
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})
|
entity_calls.append({"entity_id": str(entity_id), "operation": operation, "input": input_data})
|
||||||
return _create_entity_task()
|
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")
|
# Create agent directly with executor (not via app.get_agent)
|
||||||
agent = app.get_agent(mock_context, "WriterAgent")
|
agent = DurableAIAgent(executor, "WriterAgent")
|
||||||
|
|
||||||
# Create thread
|
# Create thread
|
||||||
thread = agent.get_new_thread()
|
thread = agent.get_new_thread()
|
||||||
@@ -517,18 +312,15 @@ class TestOrchestrationIntegration:
|
|||||||
# Verify both calls used the same entity (same session key)
|
# Verify both calls used the same entity (same session key)
|
||||||
assert len(entity_calls) == 2
|
assert len(entity_calls) == 2
|
||||||
assert entity_calls[0]["entity_id"] == entity_calls[1]["entity_id"]
|
assert entity_calls[0]["entity_id"] == entity_calls[1]["entity_id"]
|
||||||
# EntityId format is @dafx-writeragent@deterministic-guid-001
|
# EntityId format is @dafx-writeragent@<uuid_hex>
|
||||||
assert entity_calls[0]["entity_id"] == "@dafx-writeragent@deterministic-guid-001"
|
expected_entity_id = f"@dafx-writeragent@{uuid_hexes[0]}"
|
||||||
# new_uuid called 3 times: thread + 2 correlation IDs
|
assert entity_calls[0]["entity_id"] == expected_entity_id
|
||||||
assert mock_context.new_uuid.call_count == 3
|
# 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."""
|
"""Test using multiple different agents in one orchestration."""
|
||||||
mock_context = Mock()
|
executor, context, uuid_hexes = executor_with_multiple_uuids
|
||||||
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"])
|
|
||||||
|
|
||||||
entity_calls: list[str] = []
|
entity_calls: list[str] = []
|
||||||
|
|
||||||
@@ -536,11 +328,11 @@ class TestOrchestrationIntegration:
|
|||||||
entity_calls.append(str(entity_id))
|
entity_calls.append(str(entity_id))
|
||||||
return _create_entity_task()
|
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")
|
# Create agents directly with executor (not via app.get_agent)
|
||||||
writer = app.get_agent(mock_context, "WriterAgent")
|
writer = DurableAIAgent(executor, "WriterAgent")
|
||||||
editor = app.get_agent(mock_context, "EditorAgent")
|
editor = DurableAIAgent(executor, "EditorAgent")
|
||||||
|
|
||||||
writer_thread = writer.get_new_thread()
|
writer_thread = writer.get_new_thread()
|
||||||
editor_thread = editor.get_new_thread()
|
editor_thread = editor.get_new_thread()
|
||||||
@@ -554,62 +346,11 @@ class TestOrchestrationIntegration:
|
|||||||
|
|
||||||
# Verify different entity IDs were used
|
# Verify different entity IDs were used
|
||||||
assert len(entity_calls) == 2
|
assert len(entity_calls) == 2
|
||||||
# EntityId format is @dafx-agentname@guid (lowercased agent name with dafx- prefix)
|
# EntityId format is @dafx-agentname@uuid_hex (lowercased agent name with dafx- prefix)
|
||||||
assert entity_calls[0] == "@dafx-writeragent@writer-guid-001"
|
expected_writer_id = f"@dafx-writeragent@{uuid_hexes[0]}"
|
||||||
assert entity_calls[1] == "@dafx-editoragent@editor-guid-002"
|
expected_editor_id = f"@dafx-editoragent@{uuid_hexes[1]}"
|
||||||
|
assert entity_calls[0] == expected_writer_id
|
||||||
|
assert entity_calls[1] == expected_editor_id
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework."
|
|||||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
version = "1.0.0b260123"
|
version = "1.0.0b260116"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
urls.homepage = "https://aka.ms/agent-framework"
|
urls.homepage = "https://aka.ms/agent-framework"
|
||||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
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"}]
|
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
version = "1.0.0b260123"
|
version = "1.0.0b260116"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
urls.homepage = "https://aka.ms/agent-framework"
|
urls.homepage = "https://aka.ms/agent-framework"
|
||||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
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"}]
|
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
version = "1.0.0b260123"
|
version = "1.0.0b260116"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
urls.homepage = "https://aka.ms/agent-framework"
|
urls.homepage = "https://aka.ms/agent-framework"
|
||||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import importlib
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
_IMPORTS: dict[str, tuple[str, str]] = {
|
_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"),
|
"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"),
|
"AzureAIAgentClient": ("agent_framework_azure_ai", "agent-framework-azure-ai"),
|
||||||
"AzureAIAgentOptions": ("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"),
|
"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"),
|
"AzureOpenAIResponsesOptions": ("agent_framework.azure._responses_client", "agent-framework-core"),
|
||||||
"AzureOpenAISettings": ("agent_framework.azure._shared", "agent-framework-core"),
|
"AzureOpenAISettings": ("agent_framework.azure._shared", "agent-framework-core"),
|
||||||
"AzureUserSecurityContext": ("agent_framework.azure._chat_client", "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"),
|
"get_entra_auth_token": ("agent_framework.azure._entra_id_authentication", "agent-framework-core"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,14 @@ from agent_framework_azure_ai import (
|
|||||||
AzureAISettings,
|
AzureAISettings,
|
||||||
)
|
)
|
||||||
from agent_framework_azure_ai_search import AzureAISearchContextProvider, AzureAISearchSettings
|
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,
|
AgentCallbackContext,
|
||||||
AgentFunctionApp,
|
|
||||||
AgentResponseCallbackProtocol,
|
AgentResponseCallbackProtocol,
|
||||||
DurableAIAgent,
|
DurableAIAgent,
|
||||||
|
DurableAIAgentClient,
|
||||||
|
DurableAIAgentOrchestrationContext,
|
||||||
|
DurableAIAgentWorker,
|
||||||
)
|
)
|
||||||
|
|
||||||
from agent_framework.azure._assistants_client import AzureOpenAIAssistantsClient
|
from agent_framework.azure._assistants_client import AzureOpenAIAssistantsClient
|
||||||
@@ -39,5 +42,8 @@ __all__ = [
|
|||||||
"AzureOpenAIResponsesClient",
|
"AzureOpenAIResponsesClient",
|
||||||
"AzureOpenAISettings",
|
"AzureOpenAISettings",
|
||||||
"DurableAIAgent",
|
"DurableAIAgent",
|
||||||
|
"DurableAIAgentClient",
|
||||||
|
"DurableAIAgentOrchestrationContext",
|
||||||
|
"DurableAIAgentWorker",
|
||||||
"get_entra_auth_token",
|
"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"}]
|
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
version = "1.0.0b260123"
|
version = "1.0.0b260116"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
urls.homepage = "https://aka.ms/agent-framework"
|
urls.homepage = "https://aka.ms/agent-framework"
|
||||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||||
@@ -50,6 +50,7 @@ all = [
|
|||||||
"agent-framework-copilotstudio",
|
"agent-framework-copilotstudio",
|
||||||
"agent-framework-declarative",
|
"agent-framework-declarative",
|
||||||
"agent-framework-devui",
|
"agent-framework-devui",
|
||||||
|
"agent-framework-durabletask",
|
||||||
"agent-framework-lab",
|
"agent-framework-lab",
|
||||||
"agent-framework-mem0",
|
"agent-framework-mem0",
|
||||||
"agent-framework-ollama",
|
"agent-framework-ollama",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework."
|
|||||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
version = "1.0.0b260123"
|
version = "1.0.0b260116"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
urls.homepage = "https://aka.ms/agent-framework"
|
urls.homepage = "https://aka.ms/agent-framework"
|
||||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
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"}]
|
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
version = "1.0.0b260123"
|
version = "1.0.0b260116"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
urls.homepage = "https://github.com/microsoft/agent-framework"
|
urls.homepage = "https://github.com/microsoft/agent-framework"
|
||||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
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)
|
||||||
+93
-43
@@ -30,9 +30,10 @@ All classes support bidirectional conversion between:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from collections.abc import MutableMapping
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, cast
|
from typing import Any, ClassVar, cast
|
||||||
|
|
||||||
from agent_framework import (
|
from agent_framework import (
|
||||||
AgentResponse,
|
AgentResponse,
|
||||||
@@ -43,10 +44,10 @@ from agent_framework import (
|
|||||||
)
|
)
|
||||||
from dateutil import parser as date_parser
|
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
|
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):
|
class DurableAgentStateEntryJsonType(str, Enum):
|
||||||
@@ -72,7 +73,10 @@ def _parse_created_at(value: Any) -> datetime:
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
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)
|
return datetime.now(tz=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
@@ -258,7 +262,7 @@ class DurableAgentStateContent:
|
|||||||
"""Convert this durable state content back to an agent framework content object.
|
"""Convert this durable state content back to an agent framework content object.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
An agent framework content object (TextContent, FunctionCallContent, etc.)
|
An agent framework content object (Content of type `text`, `function_call`, etc.)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
NotImplementedError: Must be implemented by subclasses
|
NotImplementedError: Must be implemented by subclasses
|
||||||
@@ -269,37 +273,41 @@ class DurableAgentStateContent:
|
|||||||
def from_ai_content(content: Any) -> DurableAgentStateContent:
|
def from_ai_content(content: Any) -> DurableAgentStateContent:
|
||||||
"""Create a durable state content object from an agent framework content object.
|
"""Create a durable state content object from an agent framework content object.
|
||||||
|
|
||||||
This factory method maps agent framework content types (TextContent, FunctionCallContent,
|
This factory method maps agent framework content types to their corresponding durable state representations.
|
||||||
etc.) to their corresponding durable state representations. Unknown content types are
|
Unknown content types are wrapped in DurableAgentStateUnknownContent.
|
||||||
wrapped in DurableAgentStateUnknownContent.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
content: An agent framework content object (TextContent, FunctionCallContent, etc.)
|
content: An agent framework content object (Content of type `text`, `function_call`, etc.)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The corresponding DurableAgentStateContent subclass instance
|
The corresponding DurableAgentStateContent subclass instance
|
||||||
"""
|
"""
|
||||||
# Map AI content type to appropriate DurableAgentStateContent subclass
|
# Map AI content type to appropriate DurableAgentStateContent subclass
|
||||||
if isinstance(content, Content) and content.type == "data":
|
if not isinstance(content, Content):
|
||||||
|
return DurableAgentStateUnknownContent.from_unknown_content(content)
|
||||||
|
|
||||||
|
match content.type:
|
||||||
|
case "data":
|
||||||
return DurableAgentStateDataContent.from_data_content(content)
|
return DurableAgentStateDataContent.from_data_content(content)
|
||||||
if isinstance(content, Content) and content.type == "error":
|
case "error":
|
||||||
return DurableAgentStateErrorContent.from_error_content(content)
|
return DurableAgentStateErrorContent.from_error_content(content)
|
||||||
if isinstance(content, Content) and content.type == "function_call":
|
case "function_call":
|
||||||
return DurableAgentStateFunctionCallContent.from_function_call_content(content)
|
return DurableAgentStateFunctionCallContent.from_function_call_content(content)
|
||||||
if isinstance(content, Content) and content.type == "function_result":
|
case "function_result":
|
||||||
return DurableAgentStateFunctionResultContent.from_function_result_content(content)
|
return DurableAgentStateFunctionResultContent.from_function_result_content(content)
|
||||||
if isinstance(content, Content) and content.type == "hosted_file":
|
case "hosted_file":
|
||||||
return DurableAgentStateHostedFileContent.from_hosted_file_content(content)
|
return DurableAgentStateHostedFileContent.from_hosted_file_content(content)
|
||||||
if isinstance(content, Content) and content.type == "hosted_vector_store":
|
case "hosted_vector_store":
|
||||||
return DurableAgentStateHostedVectorStoreContent.from_hosted_vector_store_content(content)
|
return DurableAgentStateHostedVectorStoreContent.from_hosted_vector_store_content(content)
|
||||||
if isinstance(content, Content) and content.type == "text":
|
case "text":
|
||||||
return DurableAgentStateTextContent.from_text_content(content)
|
return DurableAgentStateTextContent.from_text_content(content)
|
||||||
if isinstance(content, Content) and content.type == "text_reasoning":
|
case "reasoning":
|
||||||
return DurableAgentStateTextReasoningContent.from_text_reasoning_content(content)
|
return DurableAgentStateTextReasoningContent.from_text_reasoning_content(content)
|
||||||
if isinstance(content, Content) and content.type == "uri":
|
case "uri":
|
||||||
return DurableAgentStateUriContent.from_uri_content(content)
|
return DurableAgentStateUriContent.from_uri_content(content)
|
||||||
if isinstance(content, Content) and content.type == "usage":
|
case "usage":
|
||||||
return DurableAgentStateUsageContent.from_usage_content(content)
|
return DurableAgentStateUsageContent.from_usage_content(content)
|
||||||
|
case _:
|
||||||
return DurableAgentStateUnknownContent.from_unknown_content(content)
|
return DurableAgentStateUnknownContent.from_unknown_content(content)
|
||||||
|
|
||||||
|
|
||||||
@@ -439,7 +447,7 @@ class DurableAgentState:
|
|||||||
"""Get the count of conversation entries (requests + responses)."""
|
"""Get the count of conversation entries (requests + responses)."""
|
||||||
return len(self.data.conversation_history)
|
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.
|
"""Try to get an agent response by correlation ID.
|
||||||
|
|
||||||
This method searches the conversation history for a response entry matching the given
|
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:
|
for entry in self.data.conversation_history:
|
||||||
if entry.correlation_id == correlation_id and isinstance(entry, DurableAgentStateResponse):
|
if entry.correlation_id == correlation_id and isinstance(entry, DurableAgentStateResponse):
|
||||||
# Found the entry, extract response data
|
# Found the entry, extract response data
|
||||||
# Get the text content from assistant messages only
|
return DurableAgentStateResponse.to_run_response(entry)
|
||||||
content = "\n".join(message.text for message in entry.messages if message.text)
|
|
||||||
|
|
||||||
return {
|
|
||||||
ApiResponseFields.CONTENT: content,
|
|
||||||
ApiResponseFields.MESSAGE_COUNT: self.message_count,
|
|
||||||
ApiResponseFields.CORRELATION_ID: correlation_id,
|
|
||||||
}
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -689,7 +691,22 @@ class DurableAgentStateResponse(DurableAgentStateEntry):
|
|||||||
correlation_id=correlation_id,
|
correlation_id=correlation_id,
|
||||||
created_at=_parse_created_at(response.created_at),
|
created_at=_parse_created_at(response.created_at),
|
||||||
messages=[DurableAgentStateMessage.from_chat_message(m) for m in response.messages],
|
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
|
@staticmethod
|
||||||
def from_data_content(content: Content) -> DurableAgentStateDataContent:
|
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:
|
def to_ai_content(self) -> Content:
|
||||||
return Content.from_uri(uri=self.uri, media_type=self.media_type)
|
return Content.from_uri(uri=self.uri, media_type=self.media_type)
|
||||||
@@ -940,6 +959,10 @@ class DurableAgentStateFunctionCallContent(DurableAgentStateContent):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_function_call_content(content: Content) -> DurableAgentStateFunctionCallContent:
|
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
|
# Ensure arguments is a dict; parse string if needed
|
||||||
arguments: dict[str, Any] = {}
|
arguments: dict[str, Any] = {}
|
||||||
if content.arguments:
|
if content.arguments:
|
||||||
@@ -952,7 +975,7 @@ class DurableAgentStateFunctionCallContent(DurableAgentStateContent):
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
arguments = {}
|
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:
|
def to_ai_content(self) -> Content:
|
||||||
return Content.from_function_call(call_id=self.call_id, name=self.name, arguments=self.arguments)
|
return Content.from_function_call(call_id=self.call_id, name=self.name, arguments=self.arguments)
|
||||||
@@ -988,7 +1011,9 @@ class DurableAgentStateFunctionResultContent(DurableAgentStateContent):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_function_result_content(content: Content) -> DurableAgentStateFunctionResultContent:
|
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:
|
def to_ai_content(self) -> Content:
|
||||||
return Content.from_function_result(call_id=self.call_id, result=self.result)
|
return Content.from_function_result(call_id=self.call_id, result=self.result)
|
||||||
@@ -1016,7 +1041,9 @@ class DurableAgentStateHostedFileContent(DurableAgentStateContent):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_hosted_file_content(content: Content) -> DurableAgentStateHostedFileContent:
|
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:
|
def to_ai_content(self) -> Content:
|
||||||
return Content.from_hosted_file(file_id=self.file_id)
|
return Content.from_hosted_file(file_id=self.file_id)
|
||||||
@@ -1050,7 +1077,9 @@ class DurableAgentStateHostedVectorStoreContent(DurableAgentStateContent):
|
|||||||
def from_hosted_vector_store_content(
|
def from_hosted_vector_store_content(
|
||||||
content: Content,
|
content: Content,
|
||||||
) -> DurableAgentStateHostedVectorStoreContent:
|
) -> 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:
|
def to_ai_content(self) -> Content:
|
||||||
return Content.from_hosted_vector_store(vector_store_id=self.vector_store_id)
|
return Content.from_hosted_vector_store(vector_store_id=self.vector_store_id)
|
||||||
@@ -1137,7 +1166,11 @@ class DurableAgentStateUriContent(DurableAgentStateContent):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_uri_content(content: Content) -> DurableAgentStateUriContent:
|
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:
|
def to_ai_content(self) -> Content:
|
||||||
return Content.from_uri(uri=self.uri, media_type=self.media_type)
|
return Content.from_uri(uri=self.uri, media_type=self.media_type)
|
||||||
@@ -1157,6 +1190,14 @@ class DurableAgentStateUsage:
|
|||||||
extensionData: Optional additional metadata
|
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
|
input_token_count: int | None = None
|
||||||
output_token_count: int | None = None
|
output_token_count: int | None = None
|
||||||
total_token_count: int | None = None
|
total_token_count: int | None = None
|
||||||
@@ -1194,22 +1235,31 @@ class DurableAgentStateUsage:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@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:
|
if usage is None:
|
||||||
return 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(
|
return DurableAgentStateUsage(
|
||||||
input_token_count=usage.get("input_token_count"),
|
input_token_count=usage.get(DurableAgentStateUsage._INPUT_TOKEN_COUNT),
|
||||||
output_token_count=usage.get("output_token_count"),
|
output_token_count=usage.get(DurableAgentStateUsage._OUTPUT_TOKEN_COUNT),
|
||||||
total_token_count=usage.get("total_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:
|
def to_usage_details(self) -> UsageDetails:
|
||||||
# Convert back to AI SDK UsageDetails
|
# Convert back to AI SDK UsageDetails
|
||||||
return {
|
|
||||||
"input_token_count": self.input_token_count,
|
return UsageDetails(
|
||||||
"output_token_count": self.output_token_count,
|
input_token_count=self.input_token_count,
|
||||||
"total_token_count": self.total_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):
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import inspect
|
import inspect
|
||||||
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import MutableMapping
|
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 importlib import import_module
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
import azure.durable_functions as df
|
|
||||||
from agent_framework import AgentThread, Role
|
from agent_framework import AgentThread, Role
|
||||||
|
|
||||||
from ._constants import REQUEST_RESPONSE_FORMAT_TEXT
|
from ._constants import REQUEST_RESPONSE_FORMAT_TEXT
|
||||||
@@ -32,195 +33,6 @@ else:
|
|||||||
_PydanticBaseModel = _RuntimeBaseModel
|
_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:
|
def serialize_response_format(response_format: type[BaseModel] | None) -> Any:
|
||||||
"""Serialize response format for transport across durable function boundaries."""
|
"""Serialize response format for transport across durable function boundaries."""
|
||||||
if response_format is None:
|
if response_format is None:
|
||||||
@@ -292,43 +104,48 @@ class RunRequest:
|
|||||||
role: The role of the message sender (user, system, or assistant)
|
role: The role of the message sender (user, system, or assistant)
|
||||||
response_format: Optional Pydantic BaseModel type describing the structured response format
|
response_format: Optional Pydantic BaseModel type describing the structured response format
|
||||||
enable_tool_calls: Whether to enable tool calls for this request
|
enable_tool_calls: Whether to enable tool calls for this request
|
||||||
thread_id: Optional thread ID for tracking
|
wait_for_response: If True (default), caller will wait for agent response. If False,
|
||||||
correlation_id: Optional correlation ID for tracking the response to this specific request
|
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
|
created_at: Optional timestamp when the request was created
|
||||||
orchestration_id: Optional ID of the orchestration that initiated this request
|
orchestration_id: Optional ID of the orchestration that initiated this request
|
||||||
|
options: Optional options dictionary forwarded to the agent
|
||||||
"""
|
"""
|
||||||
|
|
||||||
message: str
|
message: str
|
||||||
request_response_format: str
|
request_response_format: str
|
||||||
|
correlation_id: str
|
||||||
role: Role = Role.USER
|
role: Role = Role.USER
|
||||||
response_format: type[BaseModel] | None = None
|
response_format: type[BaseModel] | None = None
|
||||||
enable_tool_calls: bool = True
|
enable_tool_calls: bool = True
|
||||||
thread_id: str | None = None
|
wait_for_response: bool = True
|
||||||
correlation_id: str | None = None
|
created_at: datetime | None = None
|
||||||
created_at: str | None = None
|
|
||||||
orchestration_id: str | None = None
|
orchestration_id: str | None = None
|
||||||
|
options: dict[str, Any] = field(default_factory=lambda: {})
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
message: str,
|
message: str,
|
||||||
|
correlation_id: str,
|
||||||
request_response_format: str = REQUEST_RESPONSE_FORMAT_TEXT,
|
request_response_format: str = REQUEST_RESPONSE_FORMAT_TEXT,
|
||||||
role: Role | str | None = Role.USER,
|
role: Role | str | None = Role.USER,
|
||||||
response_format: type[BaseModel] | None = None,
|
response_format: type[BaseModel] | None = None,
|
||||||
enable_tool_calls: bool = True,
|
enable_tool_calls: bool = True,
|
||||||
thread_id: str | None = None,
|
wait_for_response: bool = True,
|
||||||
correlation_id: str | None = None,
|
created_at: datetime | None = None,
|
||||||
created_at: str | None = None,
|
|
||||||
orchestration_id: str | None = None,
|
orchestration_id: str | None = None,
|
||||||
|
options: dict[str, Any] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.message = message
|
self.message = message
|
||||||
|
self.correlation_id = correlation_id
|
||||||
self.role = self.coerce_role(role)
|
self.role = self.coerce_role(role)
|
||||||
self.response_format = response_format
|
self.response_format = response_format
|
||||||
self.request_response_format = request_response_format
|
self.request_response_format = request_response_format
|
||||||
self.enable_tool_calls = enable_tool_calls
|
self.enable_tool_calls = enable_tool_calls
|
||||||
self.thread_id = thread_id
|
self.wait_for_response = wait_for_response
|
||||||
self.correlation_id = correlation_id
|
self.created_at = created_at if created_at is not None else datetime.now(tz=timezone.utc)
|
||||||
self.created_at = created_at
|
|
||||||
self.orchestration_id = orchestration_id
|
self.orchestration_id = orchestration_id
|
||||||
|
self.options = options if options is not None else {}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def coerce_role(value: Role | str | None) -> Role:
|
def coerce_role(value: Role | str | None) -> Role:
|
||||||
@@ -347,33 +164,177 @@ class RunRequest:
|
|||||||
result = {
|
result = {
|
||||||
"message": self.message,
|
"message": self.message,
|
||||||
"enable_tool_calls": self.enable_tool_calls,
|
"enable_tool_calls": self.enable_tool_calls,
|
||||||
|
"wait_for_response": self.wait_for_response,
|
||||||
"role": self.role.value,
|
"role": self.role.value,
|
||||||
"request_response_format": self.request_response_format,
|
"request_response_format": self.request_response_format,
|
||||||
|
"correlationId": self.correlation_id,
|
||||||
|
"options": self.options,
|
||||||
}
|
}
|
||||||
if self.response_format:
|
if self.response_format:
|
||||||
result["response_format"] = serialize_response_format(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:
|
if self.created_at:
|
||||||
result["created_at"] = self.created_at
|
result["created_at"] = self.created_at.isoformat()
|
||||||
if self.orchestration_id:
|
if self.orchestration_id:
|
||||||
result["orchestrationId"] = self.orchestration_id
|
result["orchestrationId"] = self.orchestration_id
|
||||||
|
|
||||||
return result
|
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
|
@classmethod
|
||||||
def from_dict(cls, data: dict[str, Any]) -> RunRequest:
|
def from_dict(cls, data: dict[str, Any]) -> RunRequest:
|
||||||
"""Create RunRequest from dictionary."""
|
"""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(
|
return cls(
|
||||||
message=data.get("message", ""),
|
message=data.get("message", ""),
|
||||||
|
correlation_id=correlation_id,
|
||||||
request_response_format=data.get("request_response_format", REQUEST_RESPONSE_FORMAT_TEXT),
|
request_response_format=data.get("request_response_format", REQUEST_RESPONSE_FORMAT_TEXT),
|
||||||
role=cls.coerce_role(data.get("role")),
|
role=cls.coerce_role(data.get("role")),
|
||||||
response_format=_deserialize_response_format(data.get("response_format")),
|
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),
|
enable_tool_calls=data.get("enable_tool_calls", True),
|
||||||
thread_id=data.get("thread_id"),
|
created_at=created_at,
|
||||||
correlation_id=data.get("correlationId"),
|
|
||||||
created_at=data.get("created_at"),
|
|
||||||
orchestration_id=data.get("orchestrationId"),
|
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"}]
|
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
version = "1.0.0b260123"
|
version = "1.0.0b260116"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
urls.homepage = "https://aka.ms/agent-framework"
|
urls.homepage = "https://aka.ms/agent-framework"
|
||||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
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