mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Rebase durable task feature branch with main (#2806)
This commit is contained in:
@@ -198,7 +198,7 @@ internal sealed class A2AAgent : AIAgent
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Id => this._id ?? base.Id;
|
||||
protected override string? IdCore => this._id;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? Name => this._name ?? base.Name;
|
||||
|
||||
@@ -22,9 +22,6 @@ namespace Microsoft.Agents.AI;
|
||||
[DebuggerDisplay("{DisplayName,nq}")]
|
||||
public abstract class AIAgent
|
||||
{
|
||||
/// <summary>Default ID of this agent instance.</summary>
|
||||
private readonly string _id = Guid.NewGuid().ToString("N");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the unique identifier for this agent instance.
|
||||
/// </summary>
|
||||
@@ -37,7 +34,19 @@ public abstract class AIAgent
|
||||
/// agent instances in multi-agent scenarios. They should remain stable for the lifetime
|
||||
/// of the agent instance.
|
||||
/// </remarks>
|
||||
public virtual string Id => this._id;
|
||||
public string Id { get => this.IdCore ?? field; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
/// <summary>
|
||||
/// Gets a custom identifier for the agent, which can be overridden by derived classes.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// A string representing the agent's identifier, or <see langword="null"/> if the default ID should be used.
|
||||
/// </value>
|
||||
/// <remarks>
|
||||
/// Derived classes can override this property to provide a custom identifier.
|
||||
/// When <see langword="null"/> is returned, the <see cref="Id"/> property will use the default randomly-generated identifier.
|
||||
/// </remarks>
|
||||
protected virtual string? IdCore => null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the human-readable name of the agent.
|
||||
@@ -61,7 +70,7 @@ public abstract class AIAgent
|
||||
/// This property provides a guaranteed non-null string suitable for display in user interfaces,
|
||||
/// logs, or other contexts where a readable identifier is needed.
|
||||
/// </remarks>
|
||||
public virtual string DisplayName => this.Name ?? this.Id ?? this._id; // final fallback to _id in case Id override returns null
|
||||
public virtual string DisplayName => this.Name ?? this.Id;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a description of the agent's purpose, capabilities, or behavior.
|
||||
|
||||
@@ -25,7 +25,7 @@ namespace Microsoft.Agents.AI;
|
||||
/// Derived classes can override specific methods to add custom behavior while maintaining compatibility with the agent interface.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class DelegatingAIAgent : AIAgent
|
||||
public abstract class DelegatingAIAgent : AIAgent
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DelegatingAIAgent"/> class with the specified inner agent.
|
||||
@@ -54,7 +54,7 @@ public class DelegatingAIAgent : AIAgent
|
||||
protected AIAgent InnerAgent { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Id => this.InnerAgent.Id;
|
||||
protected override string? IdCore => this.InnerAgent.Id;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string? Name => this.InnerAgent.Name;
|
||||
|
||||
@@ -23,11 +23,6 @@ internal sealed class AzureAIProjectChatClient : DelegatingChatClient
|
||||
private readonly AgentRecord? _agentRecord;
|
||||
private readonly ChatOptions? _chatOptions;
|
||||
private readonly AgentReference _agentReference;
|
||||
/// <summary>
|
||||
/// The usage of a no-op model is a necessary change to avoid OpenAIClients to throw exceptions when
|
||||
/// used with Azure AI Agents as the model used is now defined at the agent creation time.
|
||||
/// </summary>
|
||||
private const string NoOpModel = "no-op";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AzureAIProjectChatClient"/> class.
|
||||
@@ -42,7 +37,7 @@ internal sealed class AzureAIProjectChatClient : DelegatingChatClient
|
||||
internal AzureAIProjectChatClient(AIProjectClient aiProjectClient, AgentReference agentReference, string? defaultModelId, ChatOptions? chatOptions)
|
||||
: base(Throw.IfNull(aiProjectClient)
|
||||
.GetProjectOpenAIClient()
|
||||
.GetOpenAIResponseClient(defaultModelId ?? NoOpModel)
|
||||
.GetProjectResponsesClientForAgent(agentReference)
|
||||
.AsIChatClient())
|
||||
{
|
||||
this._agentClient = aiProjectClient;
|
||||
@@ -132,13 +127,15 @@ internal sealed class AzureAIProjectChatClient : DelegatingChatClient
|
||||
|
||||
agentEnabledChatOptions.RawRepresentationFactory = (client) =>
|
||||
{
|
||||
if (originalFactory?.Invoke(this) is not ResponseCreationOptions responseCreationOptions)
|
||||
if (originalFactory?.Invoke(this) is not CreateResponseOptions responseCreationOptions)
|
||||
{
|
||||
responseCreationOptions = new ResponseCreationOptions();
|
||||
responseCreationOptions = new CreateResponseOptions();
|
||||
}
|
||||
|
||||
ResponseCreationOptionsExtensions.set_Agent(responseCreationOptions, this._agentReference);
|
||||
ResponseCreationOptionsExtensions.set_Model(responseCreationOptions, null);
|
||||
responseCreationOptions.Agent = this._agentReference;
|
||||
#pragma warning disable SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
responseCreationOptions.Patch.Remove("$.model"u8);
|
||||
#pragma warning restore SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
|
||||
return responseCreationOptions;
|
||||
};
|
||||
|
||||
@@ -400,7 +400,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
};
|
||||
|
||||
// Attempt to capture breaking glass options from the raw representation factory that match the agent definition.
|
||||
if (options.ChatOptions?.RawRepresentationFactory?.Invoke(new NoOpChatClient()) is ResponseCreationOptions respCreationOptions)
|
||||
if (options.ChatOptions?.RawRepresentationFactory?.Invoke(new NoOpChatClient()) is CreateResponseOptions respCreationOptions)
|
||||
{
|
||||
agentDefinition.ReasoningOptions = respCreationOptions.ReasoningOptions;
|
||||
}
|
||||
@@ -466,7 +466,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
};
|
||||
|
||||
// Attempt to capture breaking glass options from the raw representation factory that match the agent definition.
|
||||
if (options.ChatOptions?.RawRepresentationFactory?.Invoke(new NoOpChatClient()) is ResponseCreationOptions respCreationOptions)
|
||||
if (options.ChatOptions?.RawRepresentationFactory?.Invoke(new NoOpChatClient()) is CreateResponseOptions respCreationOptions)
|
||||
{
|
||||
agentDefinition.ReasoningOptions = respCreationOptions.ReasoningOptions;
|
||||
}
|
||||
|
||||
@@ -217,9 +217,7 @@ public class CosmosCheckpointStore<T> : JsonCheckpointStore, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a checkpoint document stored in Cosmos DB.
|
||||
/// </summary>
|
||||
/// <summary>Represents a checkpoint document stored in Cosmos DB.</summary>
|
||||
internal sealed class CosmosCheckpointDocument
|
||||
{
|
||||
[JsonProperty("id")]
|
||||
|
||||
@@ -81,7 +81,7 @@ internal sealed partial class DevUIMiddleware
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status301MovedPermanently;
|
||||
context.Response.Headers.Location = redirectUrl;
|
||||
context.Response.Headers.Location = redirectUrl; // CodeQL [SM04598] justification: The redirect URL is constructed from a server-configured base path (_basePath), not user input. The query string is only appended as parameters and cannot change the redirect destination since this is a relative URL.
|
||||
|
||||
if (this._logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
|
||||
@@ -16,29 +16,34 @@ internal class AgentEntity(IServiceProvider services, CancellationToken cancella
|
||||
private readonly DurableTaskClient _client = services.GetRequiredService<DurableTaskClient>();
|
||||
private readonly ILoggerFactory _loggerFactory = services.GetRequiredService<ILoggerFactory>();
|
||||
private readonly IAgentResponseHandler? _messageHandler = services.GetService<IAgentResponseHandler>();
|
||||
private readonly DurableAgentsOptions _options = services.GetRequiredService<DurableAgentsOptions>();
|
||||
private readonly CancellationToken _cancellationToken = cancellationToken != default
|
||||
? cancellationToken
|
||||
: services.GetService<IHostApplicationLifetime>()?.ApplicationStopping ?? CancellationToken.None;
|
||||
|
||||
public async Task<AgentRunResponse> RunAgentAsync(RunRequest request)
|
||||
public Task<AgentRunResponse> RunAgentAsync(RunRequest request)
|
||||
{
|
||||
return this.Run(request);
|
||||
}
|
||||
|
||||
// IDE1006 and VSTHRD200 disabled to allow method name to match the common cross-platform entity operation name.
|
||||
#pragma warning disable IDE1006
|
||||
#pragma warning disable VSTHRD200
|
||||
public async Task<AgentRunResponse> Run(RunRequest request)
|
||||
#pragma warning restore VSTHRD200
|
||||
#pragma warning restore IDE1006
|
||||
{
|
||||
AgentSessionId sessionId = this.Context.Id;
|
||||
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agents =
|
||||
this._services.GetRequiredService<IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>>>();
|
||||
if (!agents.TryGetValue(sessionId.Name, out Func<IServiceProvider, AIAgent>? agentFactory))
|
||||
{
|
||||
throw new InvalidOperationException($"Agent '{sessionId.Name}' not found");
|
||||
}
|
||||
|
||||
AIAgent agent = agentFactory(this._services);
|
||||
AIAgent agent = this.GetAgent(sessionId);
|
||||
EntityAgentWrapper agentWrapper = new(agent, this.Context, request, this._services);
|
||||
|
||||
// Logger category is Microsoft.DurableTask.Agents.{agentName}.{sessionId}
|
||||
ILogger logger = this._loggerFactory.CreateLogger($"Microsoft.DurableTask.Agents.{agent.Name}.{sessionId.Key}");
|
||||
ILogger logger = this.GetLogger(agent.Name!, sessionId.Key);
|
||||
|
||||
if (request.Messages.Count == 0)
|
||||
{
|
||||
logger.LogInformation("Ignoring empty request");
|
||||
return new AgentRunResponse();
|
||||
}
|
||||
|
||||
this.State.Data.ConversationHistory.Add(DurableAgentStateRequest.FromRunRequest(request));
|
||||
@@ -113,6 +118,36 @@ internal class AgentEntity(IServiceProvider services, CancellationToken cancella
|
||||
response.Usage?.TotalTokenCount);
|
||||
}
|
||||
|
||||
// Update TTL expiration time. Only schedule deletion check on first interaction.
|
||||
// Subsequent interactions just update the expiration time; CheckAndDeleteIfExpiredAsync
|
||||
// will reschedule the deletion check when it runs.
|
||||
TimeSpan? timeToLive = this._options.GetTimeToLive(sessionId.Name);
|
||||
if (timeToLive.HasValue)
|
||||
{
|
||||
DateTime newExpirationTime = DateTime.UtcNow.Add(timeToLive.Value);
|
||||
bool isFirstInteraction = this.State.Data.ExpirationTimeUtc is null;
|
||||
|
||||
this.State.Data.ExpirationTimeUtc = newExpirationTime;
|
||||
logger.LogTTLExpirationTimeUpdated(sessionId, newExpirationTime);
|
||||
|
||||
// Only schedule deletion check on the first interaction when entity is created.
|
||||
// On subsequent interactions, we just update the expiration time. The scheduled
|
||||
// CheckAndDeleteIfExpiredAsync will reschedule itself if the entity hasn't expired.
|
||||
if (isFirstInteraction)
|
||||
{
|
||||
this.ScheduleDeletionCheck(sessionId, logger, timeToLive.Value);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// TTL is disabled. Clear the expiration time if it was previously set.
|
||||
if (this.State.Data.ExpirationTimeUtc.HasValue)
|
||||
{
|
||||
logger.LogTTLExpirationTimeCleared(sessionId);
|
||||
this.State.Data.ExpirationTimeUtc = null;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
finally
|
||||
@@ -121,4 +156,78 @@ internal class AgentEntity(IServiceProvider services, CancellationToken cancella
|
||||
DurableAgentContext.ClearCurrent();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the entity has expired and deletes it if so, otherwise reschedules the deletion check.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method is called by the durable task runtime when a <c>CheckAndDeleteIfExpired</c> signal is received.
|
||||
/// </remarks>
|
||||
public void CheckAndDeleteIfExpired()
|
||||
{
|
||||
AgentSessionId sessionId = this.Context.Id;
|
||||
AIAgent agent = this.GetAgent(sessionId);
|
||||
ILogger logger = this.GetLogger(agent.Name!, sessionId.Key);
|
||||
|
||||
DateTime currentTime = DateTime.UtcNow;
|
||||
DateTime? expirationTime = this.State.Data.ExpirationTimeUtc;
|
||||
|
||||
logger.LogTTLDeletionCheck(sessionId, expirationTime, currentTime);
|
||||
|
||||
if (expirationTime.HasValue)
|
||||
{
|
||||
if (currentTime >= expirationTime.Value)
|
||||
{
|
||||
// Entity has expired, delete it
|
||||
logger.LogTTLEntityExpired(sessionId, expirationTime.Value);
|
||||
this.State = null!;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Entity hasn't expired yet, reschedule the deletion check
|
||||
TimeSpan? timeToLive = this._options.GetTimeToLive(sessionId.Name);
|
||||
if (timeToLive.HasValue)
|
||||
{
|
||||
this.ScheduleDeletionCheck(sessionId, logger, timeToLive.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ScheduleDeletionCheck(AgentSessionId sessionId, ILogger logger, TimeSpan timeToLive)
|
||||
{
|
||||
DateTime currentTime = DateTime.UtcNow;
|
||||
DateTime expirationTime = this.State.Data.ExpirationTimeUtc ?? currentTime.Add(timeToLive);
|
||||
TimeSpan minimumDelay = this._options.MinimumTimeToLiveSignalDelay;
|
||||
|
||||
// To avoid excessive scheduling, we schedule the deletion check for no less than the minimum delay.
|
||||
DateTime scheduledTime = expirationTime > currentTime.Add(minimumDelay)
|
||||
? expirationTime
|
||||
: currentTime.Add(minimumDelay);
|
||||
|
||||
logger.LogTTLDeletionScheduled(sessionId, scheduledTime);
|
||||
|
||||
// Schedule a signal to self to check for expiration
|
||||
this.Context.SignalEntity(
|
||||
this.Context.Id,
|
||||
nameof(CheckAndDeleteIfExpired), // self-signal
|
||||
options: new SignalEntityOptions { SignalTime = scheduledTime });
|
||||
}
|
||||
|
||||
private AIAgent GetAgent(AgentSessionId sessionId)
|
||||
{
|
||||
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agents =
|
||||
this._services.GetRequiredService<IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>>>();
|
||||
if (!agents.TryGetValue(sessionId.Name, out Func<IServiceProvider, AIAgent>? agentFactory))
|
||||
{
|
||||
throw new InvalidOperationException($"Agent '{sessionId.Name}' not found");
|
||||
}
|
||||
|
||||
return agentFactory(this._services);
|
||||
}
|
||||
|
||||
private ILogger GetLogger(string agentName, string sessionKey)
|
||||
{
|
||||
return this._loggerFactory.CreateLogger($"Microsoft.DurableTask.Agents.{agentName}.{sessionKey}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Release History
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- Added TTL configuration for durable agent entities ([#2679](https://github.com/microsoft/agent-framework/pull/2679))
|
||||
- Switch to new "Run" method name ([#2843](https://github.com/microsoft/agent-framework/pull/2843))
|
||||
|
||||
## v1.0.0-preview.251204.1
|
||||
|
||||
- Added orchestration ID to durable agent entity state ([#2137](https://github.com/microsoft/agent-framework/pull/2137))
|
||||
|
||||
@@ -22,7 +22,7 @@ internal class DefaultDurableAgentClient(DurableTaskClient client, ILoggerFactor
|
||||
|
||||
await this._client.Entities.SignalEntityAsync(
|
||||
sessionId,
|
||||
nameof(AgentEntity.RunAgentAsync),
|
||||
nameof(AgentEntity.Run),
|
||||
request,
|
||||
cancellation: cancellationToken);
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ public sealed class DurableAIAgent : AIAgent
|
||||
{
|
||||
return await this._context.Entities.CallEntityAsync<AgentRunResponse>(
|
||||
durableThread.SessionId,
|
||||
nameof(AgentEntity.RunAgentAsync),
|
||||
nameof(AgentEntity.Run),
|
||||
request);
|
||||
}
|
||||
catch (EntityOperationFailedException e) when (e.FailureDetails.ErrorType == "EntityTaskNotFound")
|
||||
|
||||
@@ -9,23 +9,67 @@ public sealed class DurableAgentsOptions
|
||||
{
|
||||
// Agent names are case-insensitive
|
||||
private readonly Dictionary<string, Func<IServiceProvider, AIAgent>> _agentFactories = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, TimeSpan?> _agentTimeToLive = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
internal DurableAgentsOptions()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default time-to-live (TTL) for agent entities.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// If an agent entity is idle for this duration, it will be automatically deleted.
|
||||
/// Defaults to 14 days. Set to <see langword="null"/> to disable TTL for agents without explicit TTL configuration.
|
||||
/// </remarks>
|
||||
public TimeSpan? DefaultTimeToLive { get; set; } = TimeSpan.FromDays(14);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum delay for scheduling TTL deletion signals. Defaults to 5 minutes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This property is primarily useful for testing (where shorter delays are needed) or for
|
||||
/// shorter-lived agents in workflows that need more rapid cleanup. The maximum allowed value is 5 minutes.
|
||||
/// Reducing the minimum deletion delay below 5 minutes can be useful for testing or for ensuring rapid cleanup of short-lived agent sessions.
|
||||
/// However, this can also increase the load on the system and should be used with caution.
|
||||
/// </remarks>
|
||||
/// <exception cref="ArgumentOutOfRangeException">Thrown when the value exceeds 5 minutes.</exception>
|
||||
public TimeSpan MinimumTimeToLiveSignalDelay
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
const int MaximumDelayMinutes = 5;
|
||||
if (value > TimeSpan.FromMinutes(MaximumDelayMinutes))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(value),
|
||||
value,
|
||||
$"The minimum time-to-live signal delay cannot exceed {MaximumDelayMinutes} minutes.");
|
||||
}
|
||||
|
||||
field = value;
|
||||
}
|
||||
} = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Adds an AI agent factory to the options.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="factory">The factory function to create the agent.</param>
|
||||
/// <param name="timeToLive">Optional time-to-live for this agent's entities. If not specified, uses <see cref="DefaultTimeToLive"/>.</param>
|
||||
/// <returns>The options instance.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="name"/> or <paramref name="factory"/> is null.</exception>
|
||||
public DurableAgentsOptions AddAIAgentFactory(string name, Func<IServiceProvider, AIAgent> factory)
|
||||
public DurableAgentsOptions AddAIAgentFactory(string name, Func<IServiceProvider, AIAgent> factory, TimeSpan? timeToLive = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
ArgumentNullException.ThrowIfNull(factory);
|
||||
this._agentFactories.Add(name, factory);
|
||||
if (timeToLive.HasValue)
|
||||
{
|
||||
this._agentTimeToLive[name] = timeToLive;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -50,12 +94,13 @@ public sealed class DurableAgentsOptions
|
||||
/// Adds an AI agent to the options.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent to add.</param>
|
||||
/// <param name="timeToLive">Optional time-to-live for this agent's entities. If not specified, uses <see cref="DefaultTimeToLive"/>.</param>
|
||||
/// <returns>The options instance.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="agent"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// Thrown when <paramref name="agent.Name"/> is null or whitespace or when an agent with the same name has already been registered.
|
||||
/// </exception>
|
||||
public DurableAgentsOptions AddAIAgent(AIAgent agent)
|
||||
public DurableAgentsOptions AddAIAgent(AIAgent agent, TimeSpan? timeToLive = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
|
||||
@@ -70,6 +115,11 @@ public sealed class DurableAgentsOptions
|
||||
}
|
||||
|
||||
this._agentFactories.Add(agent.Name, sp => agent);
|
||||
if (timeToLive.HasValue)
|
||||
{
|
||||
this._agentTimeToLive[agent.Name] = timeToLive;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -81,4 +131,14 @@ public sealed class DurableAgentsOptions
|
||||
{
|
||||
return this._agentFactories.AsReadOnly();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the time-to-live for a specific agent, or the default TTL if not specified.
|
||||
/// </summary>
|
||||
/// <param name="agentName">The name of the agent.</param>
|
||||
/// <returns>The time-to-live for the agent, or the default TTL if not specified.</returns>
|
||||
internal TimeSpan? GetTimeToLive(string agentName)
|
||||
{
|
||||
return this._agentTimeToLive.TryGetValue(agentName, out TimeSpan? ttl) ? ttl : this.DefaultTimeToLive;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ internal sealed class EntityAgentWrapper(
|
||||
private readonly IServiceProvider? _entityScopedServices = entityScopedServices;
|
||||
|
||||
// The ID of the agent is always the entity ID.
|
||||
public override string Id => this._entityContext.Id.ToString();
|
||||
protected override string? IdCore => this._entityContext.Id.ToString();
|
||||
|
||||
public override async Task<AgentRunResponse> RunAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
|
||||
@@ -46,4 +46,58 @@ internal static partial class Logs
|
||||
Level = LogLevel.Information,
|
||||
Message = "Found response for agent with session ID '{SessionId}' with correlation ID '{CorrelationId}'")]
|
||||
public static partial void LogDonePollingForResponse(this ILogger logger, AgentSessionId sessionId, string correlationId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 6,
|
||||
Level = LogLevel.Information,
|
||||
Message = "[{SessionId}] TTL expiration time updated to {ExpirationTime:O}")]
|
||||
public static partial void LogTTLExpirationTimeUpdated(
|
||||
this ILogger logger,
|
||||
AgentSessionId sessionId,
|
||||
DateTime expirationTime);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 7,
|
||||
Level = LogLevel.Information,
|
||||
Message = "[{SessionId}] TTL deletion signal scheduled for {ScheduledTime:O}")]
|
||||
public static partial void LogTTLDeletionScheduled(
|
||||
this ILogger logger,
|
||||
AgentSessionId sessionId,
|
||||
DateTime scheduledTime);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 8,
|
||||
Level = LogLevel.Information,
|
||||
Message = "[{SessionId}] TTL deletion check running. Expiration time: {ExpirationTime:O}, Current time: {CurrentTime:O}")]
|
||||
public static partial void LogTTLDeletionCheck(
|
||||
this ILogger logger,
|
||||
AgentSessionId sessionId,
|
||||
DateTime? expirationTime,
|
||||
DateTime currentTime);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 9,
|
||||
Level = LogLevel.Information,
|
||||
Message = "[{SessionId}] Entity expired and deleted due to TTL. Expiration time: {ExpirationTime:O}")]
|
||||
public static partial void LogTTLEntityExpired(
|
||||
this ILogger logger,
|
||||
AgentSessionId sessionId,
|
||||
DateTime expirationTime);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 10,
|
||||
Level = LogLevel.Information,
|
||||
Message = "[{SessionId}] TTL deletion signal rescheduled for {ScheduledTime:O}")]
|
||||
public static partial void LogTTLRescheduled(
|
||||
this ILogger logger,
|
||||
AgentSessionId sessionId,
|
||||
DateTime scheduledTime);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 11,
|
||||
Level = LogLevel.Information,
|
||||
Message = "[{SessionId}] TTL expiration time cleared (TTL disabled)")]
|
||||
public static partial void LogTTLExpirationTimeCleared(
|
||||
this ILogger logger,
|
||||
AgentSessionId sessionId);
|
||||
}
|
||||
|
||||
@@ -85,6 +85,9 @@ public static class ServiceCollectionExtensions
|
||||
// The agent dictionary contains the real agent factories, which is used by the agent entities.
|
||||
services.AddSingleton(agents);
|
||||
|
||||
// Register the options so AgentEntity can access TTL configuration
|
||||
services.AddSingleton(options);
|
||||
|
||||
// The keyed services are used to resolve durable agent *proxy* instances for external clients.
|
||||
foreach (var factory in agents)
|
||||
{
|
||||
|
||||
@@ -17,6 +17,13 @@ internal sealed class DurableAgentStateData
|
||||
[JsonPropertyName("conversationHistory")]
|
||||
public IList<DurableAgentStateEntry> ConversationHistory { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the expiration time (UTC) for this agent entity.
|
||||
/// If the entity is idle beyond this time, it will be automatically deleted.
|
||||
/// </summary>
|
||||
[JsonPropertyName("expirationTimeUtc")]
|
||||
public DateTime? ExpirationTimeUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets any additional data found during deserialization that does not map to known properties.
|
||||
/// </summary>
|
||||
|
||||
+11
-15
@@ -84,22 +84,18 @@ internal sealed class ConversationReferenceJsonConverter : JsonConverter<Convers
|
||||
return;
|
||||
}
|
||||
|
||||
// If only ID is present and no metadata, serialize as a simple string
|
||||
if (value.Metadata is null || value.Metadata.Count == 0)
|
||||
// Ideally if only ID is present and no metadata, we would serialize as a simple string.
|
||||
// However, while a request's "conversation" property can be either a string or an object
|
||||
// containing a string, a response's "conversation" property is always an object. Since
|
||||
// here we don't know which scenario we're in, we always serialize as an object, which works
|
||||
// in any scenario.
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("id", value.Id);
|
||||
if (value.Metadata is not null)
|
||||
{
|
||||
writer.WriteStringValue(value.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Otherwise, serialize as an object
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("id", value.Id);
|
||||
if (value.Metadata is not null)
|
||||
{
|
||||
writer.WritePropertyName("metadata");
|
||||
JsonSerializer.Serialize(writer, value.Metadata, OpenAIHostingJsonContext.Default.DictionaryStringString);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
writer.WritePropertyName("metadata");
|
||||
JsonSerializer.Serialize(writer, value.Metadata, OpenAIHostingJsonContext.Default.DictionaryStringString);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,22 +73,22 @@ public static class AIAgentWithOpenAIExtensions
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the AI agent with a collection of OpenAI response items and returns the response as a native OpenAI <see cref="OpenAIResponse"/>.
|
||||
/// Runs the AI agent with a collection of OpenAI response items and returns the response as a native OpenAI <see cref="ResponseResult"/>.
|
||||
/// </summary>
|
||||
/// <param name="agent">The AI agent to run.</param>
|
||||
/// <param name="messages">The collection of OpenAI response items to send to the agent.</param>
|
||||
/// <param name="thread">The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent response.</param>
|
||||
/// <param name="options">Optional parameters for agent invocation.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A <see cref="Task{OpenAIResponse}"/> representing the asynchronous operation that returns a native OpenAI <see cref="OpenAIResponse"/> response.</returns>
|
||||
/// <returns>A <see cref="Task{ResponseResult}"/> representing the asynchronous operation that returns a native OpenAI <see cref="ResponseResult"/> response.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="agent"/> or <paramref name="messages"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when the agent's response cannot be converted to an <see cref="OpenAIResponse"/>, typically when the underlying representation is not an OpenAI response.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when the agent's response cannot be converted to an <see cref="ResponseResult"/>, typically when the underlying representation is not an OpenAI response.</exception>
|
||||
/// <exception cref="NotSupportedException">Thrown when any message in <paramref name="messages"/> has a type that is not supported by the message conversion method.</exception>
|
||||
/// <remarks>
|
||||
/// This method converts the OpenAI response items to the Microsoft Extensions AI format using the appropriate conversion method,
|
||||
/// runs the agent with the converted message collection, and then extracts the native OpenAI <see cref="OpenAIResponse"/> from the response using <see cref="AgentRunResponseExtensions.AsOpenAIResponse"/>.
|
||||
/// runs the agent with the converted message collection, and then extracts the native OpenAI <see cref="ResponseResult"/> from the response using <see cref="AgentRunResponseExtensions.AsOpenAIResponse"/>.
|
||||
/// </remarks>
|
||||
public static async Task<OpenAIResponse> RunAsync(this AIAgent agent, IEnumerable<ResponseItem> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
public static async Task<ResponseResult> RunAsync(this AIAgent agent, IEnumerable<ResponseItem> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(agent);
|
||||
Throw.IfNull(messages);
|
||||
|
||||
@@ -29,17 +29,17 @@ public static class AgentRunResponseExtensions
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates or extracts a native OpenAI <see cref="OpenAIResponse"/> object from an <see cref="AgentRunResponse"/>.
|
||||
/// Creates or extracts a native OpenAI <see cref="ResponseResult"/> object from an <see cref="AgentRunResponse"/>.
|
||||
/// </summary>
|
||||
/// <param name="response">The agent response.</param>
|
||||
/// <returns>The OpenAI <see cref="OpenAIResponse"/> object.</returns>
|
||||
/// <returns>The OpenAI <see cref="ResponseResult"/> object.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="response"/> is <see langword="null"/>.</exception>
|
||||
public static OpenAIResponse AsOpenAIResponse(this AgentRunResponse response)
|
||||
public static ResponseResult AsOpenAIResponse(this AgentRunResponse response)
|
||||
{
|
||||
Throw.IfNull(response);
|
||||
|
||||
return
|
||||
response.RawRepresentation as OpenAIResponse ??
|
||||
response.AsChatResponse().AsOpenAIResponse();
|
||||
response.RawRepresentation as ResponseResult ??
|
||||
response.AsChatResponse().AsOpenAIResponseResult();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ using Microsoft.Shared.Diagnostics;
|
||||
namespace OpenAI.Responses;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for <see cref="OpenAIResponseClient"/>
|
||||
/// Provides extension methods for <see cref="ResponsesClient"/>
|
||||
/// to simplify the creation of AI agents that work with OpenAI services.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
@@ -20,9 +20,9 @@ namespace OpenAI.Responses;
|
||||
public static class OpenAIResponseClientExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an AI agent from an <see cref="OpenAIResponseClient"/> using the OpenAI Response API.
|
||||
/// Creates an AI agent from an <see cref="ResponsesClient"/> using the OpenAI Response API.
|
||||
/// </summary>
|
||||
/// <param name="client">The <see cref="OpenAIResponseClient" /> to use for the agent.</param>
|
||||
/// <param name="client">The <see cref="ResponsesClient" /> to use for the agent.</param>
|
||||
/// <param name="instructions">Optional system instructions that define the agent's behavior and personality.</param>
|
||||
/// <param name="name">Optional name for the agent for identification purposes.</param>
|
||||
/// <param name="description">Optional description of the agent's capabilities and purpose.</param>
|
||||
@@ -33,7 +33,7 @@ public static class OpenAIResponseClientExtensions
|
||||
/// <returns>An <see cref="ChatClientAgent"/> instance backed by the OpenAI Response service.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> is <see langword="null"/>.</exception>
|
||||
public static ChatClientAgent CreateAIAgent(
|
||||
this OpenAIResponseClient client,
|
||||
this ResponsesClient client,
|
||||
string? instructions = null,
|
||||
string? name = null,
|
||||
string? description = null,
|
||||
@@ -61,9 +61,9 @@ public static class OpenAIResponseClientExtensions
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an AI agent from an <see cref="OpenAIResponseClient"/> using the OpenAI Response API.
|
||||
/// Creates an AI agent from an <see cref="ResponsesClient"/> using the OpenAI Response API.
|
||||
/// </summary>
|
||||
/// <param name="client">The <see cref="OpenAIResponseClient" /> to use for the agent.</param>
|
||||
/// <param name="client">The <see cref="ResponsesClient" /> to use for the agent.</param>
|
||||
/// <param name="options">Full set of options to configure the agent.</param>
|
||||
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory for enabling logging within the agent.</param>
|
||||
@@ -71,7 +71,7 @@ public static class OpenAIResponseClientExtensions
|
||||
/// <returns>An <see cref="ChatClientAgent"/> instance backed by the OpenAI Response service.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> or <paramref name="options"/> is <see langword="null"/>.</exception>
|
||||
public static ChatClientAgent CreateAIAgent(
|
||||
this OpenAIResponseClient client,
|
||||
this ResponsesClient client,
|
||||
ChatClientAgentOptions options,
|
||||
Func<IChatClient, IChatClient>? clientFactory = null,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
|
||||
@@ -111,7 +111,7 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj
|
||||
if (inputArguments is not null)
|
||||
{
|
||||
JsonNode jsonNode = ConvertDictionaryToJson(inputArguments);
|
||||
ResponseCreationOptions responseCreationOptions = new();
|
||||
CreateResponseOptions responseCreationOptions = new();
|
||||
#pragma warning disable SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
responseCreationOptions.Patch.Set("$.structured_inputs"u8, BinaryData.FromString(jsonNode.ToJsonString()));
|
||||
#pragma warning restore SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
||||
@@ -206,7 +206,7 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj
|
||||
public override async Task<ChatMessage> GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
AgentResponseItem responseItem = await this.GetConversationClient().GetProjectConversationItemAsync(conversationId, messageId, include: null, cancellationToken).ConfigureAwait(false);
|
||||
ResponseItem[] items = [responseItem.AsOpenAIResponseItem()];
|
||||
ResponseItem[] items = [responseItem.AsResponseResultItem()];
|
||||
return items.AsChatMessages().Single();
|
||||
}
|
||||
|
||||
@@ -223,7 +223,7 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj
|
||||
|
||||
await foreach (AgentResponseItem responseItem in this.GetConversationClient().GetProjectConversationItemsAsync(conversationId, null, limit, order.ToString(), after, before, include: null, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
ResponseItem[] items = [responseItem.AsOpenAIResponseItem()];
|
||||
ResponseItem[] items = [responseItem.AsResponseResultItem()];
|
||||
foreach (ChatMessage message in items.AsChatMessages())
|
||||
{
|
||||
yield return message;
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace Microsoft.Agents.AI.Workflows;
|
||||
/// </summary>
|
||||
public sealed class DirectEdgeData : EdgeData
|
||||
{
|
||||
internal DirectEdgeData(string sourceId, string sinkId, EdgeId id, PredicateT? condition = null) : base(id)
|
||||
internal DirectEdgeData(string sourceId, string sinkId, EdgeId id, PredicateT? condition = null, string? label = null) : base(id, label)
|
||||
{
|
||||
this.SourceId = sourceId;
|
||||
this.SinkId = sinkId;
|
||||
|
||||
@@ -14,10 +14,16 @@ public abstract class EdgeData
|
||||
/// </summary>
|
||||
internal abstract EdgeConnection Connection { get; }
|
||||
|
||||
internal EdgeData(EdgeId id)
|
||||
internal EdgeData(EdgeId id, string? label = null)
|
||||
{
|
||||
this.Id = id;
|
||||
this.Label = label;
|
||||
}
|
||||
|
||||
internal EdgeId Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// An optional label for the edge, allowing for arbitrary metadata to be associated with it.
|
||||
/// </summary>
|
||||
public string? Label { get; }
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace Microsoft.Agents.AI.Workflows;
|
||||
/// </summary>
|
||||
internal sealed class FanInEdgeData : EdgeData
|
||||
{
|
||||
internal FanInEdgeData(List<string> sourceIds, string sinkId, EdgeId id) : base(id)
|
||||
internal FanInEdgeData(List<string> sourceIds, string sinkId, EdgeId id, string? label) : base(id, label)
|
||||
{
|
||||
this.SourceIds = sourceIds;
|
||||
this.SinkId = sinkId;
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace Microsoft.Agents.AI.Workflows;
|
||||
/// </summary>
|
||||
internal sealed class FanOutEdgeData : EdgeData
|
||||
{
|
||||
internal FanOutEdgeData(string sourceId, List<string> sinkIds, EdgeId edgeId, AssignerF? assigner = null) : base(edgeId)
|
||||
internal FanOutEdgeData(string sourceId, List<string> sinkIds, EdgeId edgeId, AssignerF? assigner = null, string? label = null) : base(edgeId, label)
|
||||
{
|
||||
this.SourceId = sourceId;
|
||||
this.SinkIds = sinkIds;
|
||||
|
||||
@@ -99,10 +99,30 @@ public static class WorkflowVisualizer
|
||||
}
|
||||
|
||||
// Emit normal edges
|
||||
foreach (var (src, target, isConditional) in ComputeNormalEdges(workflow))
|
||||
foreach (var (src, target, isConditional, label) in ComputeNormalEdges(workflow))
|
||||
{
|
||||
var edgeAttr = isConditional ? " [style=dashed, label=\"conditional\"]" : "";
|
||||
lines.Add($"{indent}\"{MapId(src)}\" -> \"{MapId(target)}\"{edgeAttr};");
|
||||
// Build edge attributes
|
||||
var attributes = new List<string>();
|
||||
|
||||
// Add style for conditional edges
|
||||
if (isConditional)
|
||||
{
|
||||
attributes.Add("style=dashed");
|
||||
}
|
||||
|
||||
// Add label (custom label or default "conditional" for conditional edges)
|
||||
if (label != null)
|
||||
{
|
||||
attributes.Add($"label=\"{EscapeDotLabel(label)}\"");
|
||||
}
|
||||
else if (isConditional)
|
||||
{
|
||||
attributes.Add("label=\"conditional\"");
|
||||
}
|
||||
|
||||
// Combine attributes
|
||||
var attrString = attributes.Count > 0 ? $" [{string.Join(", ", attributes)}]" : "";
|
||||
lines.Add($"{indent}\"{MapId(src)}\" -> \"{MapId(target)}\"{attrString};");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,12 +153,7 @@ public static class WorkflowVisualizer
|
||||
|
||||
private static void EmitWorkflowMermaid(Workflow workflow, List<string> lines, string indent, string? ns = null)
|
||||
{
|
||||
string sanitize(string input)
|
||||
{
|
||||
return input;
|
||||
}
|
||||
|
||||
string MapId(string id) => ns != null ? $"{sanitize(ns)}/{sanitize(id)}" : id;
|
||||
string MapId(string id) => ns != null ? $"{ns}/{id}" : id;
|
||||
|
||||
// Add start node
|
||||
var startExecutorId = workflow.StartExecutorId;
|
||||
@@ -175,14 +190,23 @@ public static class WorkflowVisualizer
|
||||
}
|
||||
|
||||
// Emit normal edges
|
||||
foreach (var (src, target, isConditional) in ComputeNormalEdges(workflow))
|
||||
foreach (var (src, target, isConditional, label) in ComputeNormalEdges(workflow))
|
||||
{
|
||||
if (isConditional)
|
||||
{
|
||||
lines.Add($"{indent}{MapId(src)} -. conditional .--> {MapId(target)};");
|
||||
string effectiveLabel = label != null ? EscapeMermaidLabel(label) : "conditional";
|
||||
|
||||
// Conditional edge, with user label or default
|
||||
lines.Add($"{indent}{MapId(src)} -. {effectiveLabel} .--> {MapId(target)};");
|
||||
}
|
||||
else if (label != null)
|
||||
{
|
||||
// Regular edge with label
|
||||
lines.Add($"{indent}{MapId(src)} -->|{EscapeMermaidLabel(label)}| {MapId(target)};");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Regular edge without label
|
||||
lines.Add($"{indent}{MapId(src)} --> {MapId(target)};");
|
||||
}
|
||||
}
|
||||
@@ -214,9 +238,9 @@ public static class WorkflowVisualizer
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<(string Source, string Target, bool IsConditional)> ComputeNormalEdges(Workflow workflow)
|
||||
private static List<(string Source, string Target, bool IsConditional, string? Label)> ComputeNormalEdges(Workflow workflow)
|
||||
{
|
||||
var edges = new List<(string, string, bool)>();
|
||||
var edges = new List<(string, string, bool, string?)>();
|
||||
foreach (var edgeGroup in workflow.Edges.Values.SelectMany(x => x))
|
||||
{
|
||||
if (edgeGroup.Kind == EdgeKind.FanIn)
|
||||
@@ -229,14 +253,15 @@ public static class WorkflowVisualizer
|
||||
case EdgeKind.Direct when edgeGroup.DirectEdgeData != null:
|
||||
var directData = edgeGroup.DirectEdgeData;
|
||||
var isConditional = directData.Condition != null;
|
||||
edges.Add((directData.SourceId, directData.SinkId, isConditional));
|
||||
var label = directData.Label;
|
||||
edges.Add((directData.SourceId, directData.SinkId, isConditional, label));
|
||||
break;
|
||||
|
||||
case EdgeKind.FanOut when edgeGroup.FanOutEdgeData != null:
|
||||
var fanOutData = edgeGroup.FanOutEdgeData;
|
||||
foreach (var sinkId in fanOutData.SinkIds)
|
||||
{
|
||||
edges.Add((fanOutData.SourceId, sinkId, false));
|
||||
edges.Add((fanOutData.SourceId, sinkId, false, fanOutData.Label));
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -276,5 +301,24 @@ public static class WorkflowVisualizer
|
||||
return false;
|
||||
}
|
||||
|
||||
// Helper method to escape special characters in DOT labels
|
||||
private static string EscapeDotLabel(string label)
|
||||
{
|
||||
return label.Replace("\"", "\\\"").Replace("\n", "\\n");
|
||||
}
|
||||
|
||||
// Helper method to escape special characters in Mermaid labels
|
||||
private static string EscapeMermaidLabel(string label)
|
||||
{
|
||||
return label
|
||||
.Replace("&", "&") // Must be first to avoid double-escaping
|
||||
.Replace("|", "|") // Pipe breaks Mermaid delimiter syntax
|
||||
.Replace("\"", """) // Quote character
|
||||
.Replace("<", "<") // Less than
|
||||
.Replace(">", ">") // Greater than
|
||||
.Replace("\n", "<br/>") // Newline to HTML break
|
||||
.Replace("\r", ""); // Remove carriage return
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -168,6 +168,18 @@ public class WorkflowBuilder
|
||||
return edges;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a directed edge from the specified source executor to the target executor, optionally guarded by a
|
||||
/// condition.
|
||||
/// </summary>
|
||||
/// <param name="source">The executor that acts as the source node of the edge. Cannot be null.</param>
|
||||
/// <param name="target">The executor that acts as the target node of the edge. Cannot be null.</param>
|
||||
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
|
||||
/// executors already exists.</exception>
|
||||
public WorkflowBuilder AddEdge(ExecutorBinding source, ExecutorBinding target)
|
||||
=> this.AddEdge<object>(source, target, null, false);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a directed edge from the specified source executor to the target executor, optionally guarded by a
|
||||
/// condition.
|
||||
@@ -182,6 +194,20 @@ public class WorkflowBuilder
|
||||
public WorkflowBuilder AddEdge(ExecutorBinding source, ExecutorBinding target, bool idempotent = false)
|
||||
=> this.AddEdge<object>(source, target, null, idempotent);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a directed edge from the specified source executor to the target executor.
|
||||
/// </summary>
|
||||
/// <param name="source">The executor that acts as the source node of the edge. Cannot be null.</param>
|
||||
/// <param name="target">The executor that acts as the target node of the edge. Cannot be null.</param>
|
||||
/// <param name="label">An optional label for the edge. Will be used in visualizations.</param>
|
||||
/// <param name="idempotent">If set to <see langword="true"/>, adding the same edge multiple times will be a NoOp,
|
||||
/// rather than an error.</param>
|
||||
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
|
||||
/// executors already exists.</exception>
|
||||
public WorkflowBuilder AddEdge(ExecutorBinding source, ExecutorBinding target, string? label = null, bool idempotent = false)
|
||||
=> this.AddEdge<object>(source, target, null, label, idempotent);
|
||||
|
||||
internal static Func<object?, bool>? CreateConditionFunc<T>(Func<T?, bool>? condition)
|
||||
{
|
||||
if (condition is null)
|
||||
@@ -222,6 +248,20 @@ public class WorkflowBuilder
|
||||
|
||||
private EdgeId TakeEdgeId() => new(Interlocked.Increment(ref this._edgeCount));
|
||||
|
||||
/// <summary>
|
||||
/// Adds a directed edge from the specified source executor to the target executor, optionally guarded by a
|
||||
/// condition.
|
||||
/// </summary>
|
||||
/// <param name="source">The executor that acts as the source node of the edge. Cannot be null.</param>
|
||||
/// <param name="target">The executor that acts as the target node of the edge. Cannot be null.</param>
|
||||
/// <param name="condition">An optional predicate that determines whether the edge should be followed based on the input.
|
||||
/// If null, the edge is always activated when the source sends a message.</param>
|
||||
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
|
||||
/// executors already exists.</exception>
|
||||
public WorkflowBuilder AddEdge<T>(ExecutorBinding source, ExecutorBinding target, Func<T?, bool>? condition = null)
|
||||
=> this.AddEdge(source, target, condition, label: null, false);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a directed edge from the specified source executor to the target executor, optionally guarded by a
|
||||
/// condition.
|
||||
@@ -236,6 +276,23 @@ public class WorkflowBuilder
|
||||
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
|
||||
/// executors already exists.</exception>
|
||||
public WorkflowBuilder AddEdge<T>(ExecutorBinding source, ExecutorBinding target, Func<T?, bool>? condition = null, bool idempotent = false)
|
||||
=> this.AddEdge(source, target, condition, label: null, idempotent);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a directed edge from the specified source executor to the target executor, optionally guarded by a
|
||||
/// condition.
|
||||
/// </summary>
|
||||
/// <param name="source">The executor that acts as the source node of the edge. Cannot be null.</param>
|
||||
/// <param name="target">The executor that acts as the target node of the edge. Cannot be null.</param>
|
||||
/// <param name="condition">An optional predicate that determines whether the edge should be followed based on the input.
|
||||
/// <param name="label">An optional label for the edge. Will be used in visualizations.</param>
|
||||
/// <param name="idempotent">If set to <see langword="true"/>, adding the same edge multiple times will be a NoOp,
|
||||
/// rather than an error.</param>
|
||||
/// If null, the edge is always activated when the source sends a message.</param>
|
||||
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
|
||||
/// executors already exists.</exception>
|
||||
public WorkflowBuilder AddEdge<T>(ExecutorBinding source, ExecutorBinding target, Func<T?, bool>? condition = null, string? label = null, bool idempotent = false)
|
||||
{
|
||||
// Add an edge from source to target with an optional condition.
|
||||
// This is a low-level builder method that does not enforce any specific executor type.
|
||||
@@ -256,7 +313,7 @@ public class WorkflowBuilder
|
||||
"You cannot add another edge without a condition for the same source and target.");
|
||||
}
|
||||
|
||||
DirectEdgeData directEdge = new(this.Track(source).Id, this.Track(target).Id, this.TakeEdgeId(), CreateConditionFunc(condition));
|
||||
DirectEdgeData directEdge = new(this.Track(source).Id, this.Track(target).Id, this.TakeEdgeId(), CreateConditionFunc(condition), label);
|
||||
|
||||
this.EnsureEdgesFor(source.Id).Add(new(directEdge));
|
||||
|
||||
@@ -275,6 +332,19 @@ public class WorkflowBuilder
|
||||
public WorkflowBuilder AddFanOutEdge(ExecutorBinding source, IEnumerable<ExecutorBinding> targets)
|
||||
=> this.AddFanOutEdge<object>(source, targets, null);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a fan-out edge from the specified source executor to one or more target executors, optionally using a
|
||||
/// custom partitioning function.
|
||||
/// </summary>
|
||||
/// <remarks>If a partitioner function is provided, it will be used to distribute input across the target
|
||||
/// executors. The order of targets determines their mapping in the partitioning process.</remarks>
|
||||
/// <param name="source">The source executor from which the fan-out edge originates. Cannot be null.</param>
|
||||
/// <param name="targets">One or more target executors that will receive the fan-out edge. Cannot be null or empty.</param>
|
||||
/// <param name="label">A label for the edge. Will be used in visualization.</param>
|
||||
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
|
||||
public WorkflowBuilder AddFanOutEdge(ExecutorBinding source, IEnumerable<ExecutorBinding> targets, string label)
|
||||
=> this.AddFanOutEdge<object>(source, targets, null, label);
|
||||
|
||||
internal static Func<object?, int, IEnumerable<int>>? CreateTargetAssignerFunc<T>(Func<T?, int, IEnumerable<int>>? targetAssigner)
|
||||
{
|
||||
if (targetAssigner is null)
|
||||
@@ -305,6 +375,21 @@ public class WorkflowBuilder
|
||||
/// <param name="targetSelector">An optional function that determines how input is assigned among the target executors.
|
||||
/// If null, messages will route to all targets.</param>
|
||||
public WorkflowBuilder AddFanOutEdge<T>(ExecutorBinding source, IEnumerable<ExecutorBinding> targets, Func<T?, int, IEnumerable<int>>? targetSelector = null)
|
||||
=> this.AddFanOutEdge(source, targets, targetSelector, label: null);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a fan-out edge from the specified source executor to one or more target executors, optionally using a
|
||||
/// custom partitioning function.
|
||||
/// </summary>
|
||||
/// <remarks>If a partitioner function is provided, it will be used to distribute input across the target
|
||||
/// executors. The order of targets determines their mapping in the partitioning process.</remarks>
|
||||
/// <param name="source">The source executor from which the fan-out edge originates. Cannot be null.</param>
|
||||
/// <param name="targets">One or more target executors that will receive the fan-out edge. Cannot be null or empty.</param>
|
||||
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
|
||||
/// <param name="targetSelector">An optional function that determines how input is assigned among the target executors.
|
||||
/// If null, messages will route to all targets.</param>
|
||||
/// <param name="label">An optional label for the edge. Will be used in visualizations.</param>
|
||||
public WorkflowBuilder AddFanOutEdge<T>(ExecutorBinding source, IEnumerable<ExecutorBinding> targets, Func<T?, int, IEnumerable<int>>? targetSelector = null, string? label = null)
|
||||
{
|
||||
Throw.IfNull(source);
|
||||
Throw.IfNull(targets);
|
||||
@@ -321,7 +406,8 @@ public class WorkflowBuilder
|
||||
this.Track(source).Id,
|
||||
sinkIds,
|
||||
this.TakeEdgeId(),
|
||||
CreateTargetAssignerFunc(targetSelector));
|
||||
CreateTargetAssignerFunc(targetSelector),
|
||||
label);
|
||||
|
||||
this.EnsureEdgesFor(source.Id).Add(new(fanOutEdge));
|
||||
|
||||
@@ -339,6 +425,20 @@ public class WorkflowBuilder
|
||||
/// <param name="target">The target executor that receives input from the specified source executors. Cannot be null.</param>
|
||||
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
|
||||
public WorkflowBuilder AddFanInEdge(IEnumerable<ExecutorBinding> sources, ExecutorBinding target)
|
||||
=> this.AddFanInEdge(sources, target, label: null);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a fan-in edge to the workflow, connecting multiple source executors to a single target executor with an
|
||||
/// optional trigger condition.
|
||||
/// </summary>
|
||||
/// <remarks>This method establishes a fan-in relationship, allowing the target executor to be activated
|
||||
/// based on the completion or state of multiple sources. The trigger parameter can be used to customize activation
|
||||
/// behavior.</remarks>
|
||||
/// <param name="sources">One or more source executors that provide input to the target. Cannot be null or empty.</param>
|
||||
/// <param name="target">The target executor that receives input from the specified source executors. Cannot be null.</param>
|
||||
/// <param name="label">An optional label for the edge. Will be used in visualizations.</param>
|
||||
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
|
||||
public WorkflowBuilder AddFanInEdge(IEnumerable<ExecutorBinding> sources, ExecutorBinding target, string? label = null)
|
||||
{
|
||||
Throw.IfNull(target);
|
||||
Throw.IfNull(sources);
|
||||
@@ -354,7 +454,8 @@ public class WorkflowBuilder
|
||||
FanInEdgeData edgeData = new(
|
||||
sourceIds,
|
||||
this.Track(target).Id,
|
||||
this.TakeEdgeId());
|
||||
this.TakeEdgeId(),
|
||||
label);
|
||||
|
||||
foreach (string sourceId in edgeData.SourceIds)
|
||||
{
|
||||
|
||||
@@ -39,7 +39,7 @@ internal sealed class WorkflowHostAgent : AIAgent
|
||||
this._describeTask = this._workflow.DescribeProtocolAsync().AsTask();
|
||||
}
|
||||
|
||||
public override string Id => this._id ?? base.Id;
|
||||
protected override string? IdCore => this._id;
|
||||
public override string? Name { get; }
|
||||
public override string? Description { get; }
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
public IChatClient ChatClient { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Id => this._agentOptions?.Id ?? base.Id;
|
||||
protected override string? IdCore => this._agentOptions?.Id;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? Name => this._agentOptions?.Name;
|
||||
|
||||
Reference in New Issue
Block a user