Initial draft of actor runtime abstractions (#197)

* Initial draft of actor runtime abstractions
This commit is contained in:
Reuben Bond
2025-07-22 16:05:58 -04:00
committed by GitHub
parent 8f2d3da80d
commit 41d441420e
108 changed files with 7445 additions and 58 deletions
@@ -2,6 +2,8 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
@@ -9,6 +11,7 @@ namespace Microsoft.Extensions.AI.Agents.Runtime;
/// Provides a unique identifier for an actor instance within an agent runtime,
/// serving as the "address" of the actor instance for receiving messages.
/// </summary>
[JsonConverter(typeof(Converter))]
public readonly struct ActorId : IEquatable<ActorId>
{
/// <summary>
@@ -113,4 +116,28 @@ public readonly struct ActorId : IEquatable<ActorId>
return true;
#endif
}
/// <summary>
/// JSON converter for <see cref="ActorId"/>.
/// </summary>
public sealed class Converter : JsonConverter<ActorId>
{
/// <inheritdoc/>
public override ActorId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.String)
{
throw new JsonException("Expected string value for ActorId");
}
string? actorIdString = reader.GetString() ?? throw new JsonException("ActorId cannot be null");
return ActorId.Parse(actorIdString);
}
/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, ActorId value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.ToString());
}
}
}
@@ -0,0 +1,41 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Source-generated JSON type information for use by all Actor abstractions.
/// </summary>
[JsonSourceGenerationOptions(
JsonSerializerDefaults.Web,
UseStringEnumConverter = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false)]
[JsonSerializable(typeof(ActorMessage))]
[JsonSerializable(typeof(ActorRequestMessage))]
[JsonSerializable(typeof(ActorResponseMessage))]
[JsonSerializable(typeof(ActorWriteOperation))]
[JsonSerializable(typeof(SetValueOperation))]
[JsonSerializable(typeof(RemoveKeyOperation))]
[JsonSerializable(typeof(SendRequestOperation))]
[JsonSerializable(typeof(UpdateRequestOperation))]
[JsonSerializable(typeof(ActorReadOperation))]
[JsonSerializable(typeof(ListKeysOperation))]
[JsonSerializable(typeof(GetValueOperation))]
[JsonSerializable(typeof(ActorReadResult))]
[JsonSerializable(typeof(ListKeysResult))]
[JsonSerializable(typeof(GetValueResult))]
[JsonSerializable(typeof(ActorRequest))]
[JsonSerializable(typeof(ActorRequestUpdate))]
[JsonSerializable(typeof(ActorResponse))]
[JsonSerializable(typeof(ActorId))]
[JsonSerializable(typeof(RequestStatus))]
[JsonSerializable(typeof(ActorWriteOperationBatch))]
[JsonSerializable(typeof(ActorReadOperationBatch))]
[JsonSerializable(typeof(ReadResponse))]
[JsonSerializable(typeof(WriteResponse))]
[JsonSerializable(typeof(ActorType))]
[JsonSerializable(typeof(JsonElement))]
internal sealed partial class ActorJsonContext : JsonSerializerContext;
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Base class for all actor messages that can be sent between actors.
/// </summary>
/// <remarks>
/// This abstract class serves as the foundation for all actor message types.
/// Each concrete implementation represents a specific type of message,
/// such as request messages or response messages.
/// </remarks>
//[JsonConverter(typeof(Converter))]
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(ActorRequestMessage), "request")]
[JsonDerivedType(typeof(ActorResponseMessage), "response")]
public abstract class ActorMessage
{
/// <summary>Prevent external derivations.</summary>
private protected ActorMessage()
{
}
/// <summary>
/// Gets the type of the message.
/// </summary>
[JsonIgnore]
public abstract ActorMessageType Type { get; }
/// <summary>
/// Additional properties that can be used to extend the message with custom data.
/// </summary>
[JsonExtensionData]
public Dictionary<string, JsonElement>? ExtensionData { get; set; }
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Specifies the type of actor message.
/// </summary>
public enum ActorMessageType
{
/// <summary>
/// Represents a request message sent to an actor.
/// </summary>
[JsonStringEnumMemberName("request")]
Request,
/// <summary>
/// Represents a response message sent from an actor.
/// </summary>
[JsonStringEnumMemberName("response")]
Response
}
@@ -0,0 +1,14 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Base class for write operations that modify an actor's messaging (inbox/outbox).
/// </summary>
public abstract class ActorMessageWriteOperation : ActorWriteOperation
{
/// <summary>Prevent external derivations.</summary>
private protected ActorMessageWriteOperation()
{
}
}
@@ -0,0 +1,31 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Base class for all actor read operations that can query actor state or messaging.
/// </summary>
/// <remarks>
/// This abstract class serves as the foundation for all actor read operation types.
/// Each concrete implementation represents a specific type of read operation,
/// such as querying actor state or messaging information.
/// </remarks>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(ListKeysOperation), "list_keys")]
[JsonDerivedType(typeof(GetValueOperation), "get_value")]
public abstract class ActorReadOperation
{
/// <summary>Prevent external derivations.</summary>
private protected ActorReadOperation()
{
}
/// <summary>
/// Gets the type of the read operation.
/// </summary>
[JsonIgnore]
public abstract ActorReadOperationType Type { get; }
}
@@ -0,0 +1,19 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Represents a batch of read operations to be performed on an actor.
/// </summary>
/// <param name="operations">The collection of read operations to perform.</param>
public class ActorReadOperationBatch(IReadOnlyList<ActorReadOperation> operations)
{
/// <summary>
/// Gets the collection of read operations to perform.
/// </summary>
[JsonPropertyName("operations")]
public IReadOnlyList<ActorReadOperation> Operations { get; } = operations;
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Specifies the type of actor read operation.
/// </summary>
public enum ActorReadOperationType
{
/// <summary>
/// Represents a list keys operation.
/// </summary>
[JsonStringEnumMemberName("list_keys")]
ListKeys,
/// <summary>
/// Represents a get value operation.
/// </summary>
[JsonStringEnumMemberName("get_value")]
GetValue
}
@@ -0,0 +1,31 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Base class for all actor read operation results.
/// </summary>
/// <remarks>
/// This abstract class serves as the foundation for all actor read operation result types.
/// Each concrete implementation represents a specific type of read operation result,
/// such as listing keys or retrieving values from an actor's state.
/// </remarks>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(ListKeysResult), "list_keys")]
[JsonDerivedType(typeof(GetValueOperation), "get_value")]
public abstract class ActorReadResult
{
/// <summary>Prevent external derivations.</summary>
private protected ActorReadResult()
{
}
/// <summary>
/// Gets the type of the read result operation.
/// </summary>
[JsonIgnore]
public abstract ActorReadResultType Type { get; }
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Specifies the type of actor read result operation.
/// </summary>
public enum ActorReadResultType
{
/// <summary>
/// Represents a list keys operation result.
/// </summary>
[JsonStringEnumMemberName("list_keys")]
ListKeys,
/// <summary>
/// Represents a get value operation result.
/// </summary>
[JsonStringEnumMemberName("get_value")]
GetValue
}
@@ -0,0 +1,36 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Represents a request to be sent to an actor.
/// </summary>
public class ActorRequest(ActorId actorId, string messageId, string method, JsonElement @params)
{
/// <summary>
/// Gets or sets the identifier of the target actor.
/// </summary>
[JsonPropertyName("actorId")]
public ActorId ActorId { get; } = actorId;
/// <summary>
/// Gets or sets the unique identifier for this request.
/// </summary>
[JsonPropertyName("messageId")]
public string MessageId { get; } = messageId;
/// <summary>
/// Gets or sets the method name to invoke on the actor.
/// </summary>
[JsonPropertyName("method")]
public string Method { get; } = method;
/// <summary>
/// Gets or sets the parameters for the method invocation.
/// </summary>
[JsonPropertyName("params")]
public JsonElement Params { get; } = @params;
}
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Base class for request messages sent to actors.
/// </summary>
public sealed class ActorRequestMessage(string MessageId) : ActorMessage
{
/// <inheritdoc/>
public override ActorMessageType Type => ActorMessageType.Request;
/// <summary>
/// Gets or sets the actor ID of the sender.
/// </summary>
[JsonPropertyName("sender")]
public ActorId? SenderId { get; init; }
/// <summary>
/// Gets or sets the unique identifier for the request.
/// </summary>
[JsonPropertyName("messageId")]
public string MessageId { get; } = MessageId;
/// <summary>
/// Name of the method to invoke.
/// </summary>
[JsonPropertyName("method")]
public string? Method { get; init; }
/// <summary>
/// Optional parameters for the method invocation.
/// </summary>
[JsonPropertyName("params")]
public JsonElement Params { get; init; }
}
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
// External (client) interface.
/// <summary>
/// Represents an update to an actor request's status and data.
/// </summary>
public class ActorRequestUpdate(RequestStatus status, JsonElement data)
{
/// <summary>
/// Gets the updated status of the request.
/// </summary>
[JsonPropertyName("status")]
public RequestStatus Status { get; } = status;
/// <summary>
/// Gets the updated data associated with the request.
/// </summary>
[JsonPropertyName("data")]
public JsonElement Data { get; } = data;
}
@@ -0,0 +1,36 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Represents a response handle for an actor request, providing access to the result and status updates.
/// </summary>
public class ActorResponse
{
/// <summary>
/// Gets the identifier of the actor that is processing the request.
/// </summary>
[JsonPropertyName("actorId")]
public ActorId ActorId { get; init; }
/// <summary>
/// Gets the unique identifier of the message/request.
/// </summary>
[JsonPropertyName("messageId")]
public string? MessageId { get; init; }
/// <summary>
/// Gets the response data from the actor.
/// </summary>
[JsonPropertyName("data")]
public JsonElement Data { get; init; }
/// <summary>
/// Gets or sets the current status of the request.
/// </summary>
[JsonPropertyName("status")]
public RequestStatus Status { get; init; }
}
@@ -0,0 +1,46 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Represents a handle to an actor response, allowing retrieval of the response data and status updates.
/// </summary>
public abstract class ActorResponseHandle
{
/// <summary>
/// Attempts to get the response from the request if it is immediately available.
/// </summary>
/// <param name="response">When this method returns <see langword="true"/>, contains the actor response; otherwise, <see langword="null"/>.</param>
/// <returns><see langword="true"/> if the response is immediately available; otherwise, <see langword="false"/>.</returns>
/// <remarks>
/// This method does not block and returns immediately. If the request is still pending or processing,
/// this method returns <see langword="false"/>.
/// Use <see cref="GetResponseAsync(CancellationToken)"/> to wait asynchronously for the response to become available.
/// </remarks>
public abstract bool TryGetResponse([NotNullWhen(true)] out ActorResponse? response);
/// <summary>
/// Gets the response from the completed request.
/// </summary>
/// <param name="cancellationToken">A token to cancel the wait operation.</param>
/// <returns>A task that completes when the request is finished.</returns>
public abstract ValueTask<ActorResponse> GetResponseAsync(CancellationToken cancellationToken);
/// <summary>
/// Cancels the request if it is still pending.
/// </summary>
/// <returns>A task representing the cancellation operation.</returns>
public abstract ValueTask CancelAsync(CancellationToken cancellationToken);
/// <summary>
/// Watches for status and data updates to the request.
/// </summary>
/// <param name="cancellationToken">A token to cancel the watch operation.</param>
/// <returns>An asynchronous enumerable of request updates.</returns>
public abstract IAsyncEnumerable<ActorRequestUpdate> WatchUpdatesAsync(CancellationToken cancellationToken);
}
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Base class for response messages sent from actors.
/// </summary>
public sealed class ActorResponseMessage(string MessageId) : ActorMessage
{
/// <inheritdoc/>
public override ActorMessageType Type => ActorMessageType.Response;
/// <summary>
/// Gets or sets the actor ID of the sender.
/// </summary>
[JsonPropertyName("senderId")]
public ActorId SenderId { get; init; }
/// <summary>
/// Gets or sets the unique identifier for the request.
/// </summary>
[JsonPropertyName("messageId")]
public string MessageId { get; } = MessageId;
/// <summary>
/// Gets or sets the status of the request.
/// </summary>
[JsonPropertyName("status")]
public RequestStatus Status { get; init; }
/// <summary>
/// Gets or sets the response data (result or error information).
/// </summary>
[JsonPropertyName("data")]
public JsonElement Data { get; init; }
}
@@ -0,0 +1,14 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Base class for read operations that query an actor's internal state.
/// </summary>
public abstract class ActorStateReadOperation : ActorReadOperation
{
/// <summary>Prevent external derivations.</summary>
private protected ActorStateReadOperation()
{
}
}
@@ -0,0 +1,19 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Base class for write operations that modify an actor's internal state.
/// </summary>
/// <remarks>
/// This abstract class serves as the foundation for all actor state write operation types.
/// Each concrete implementation represents a specific type of state modification operation,
/// such as setting or removing key-value pairs in an actor's state.
/// </remarks>
public abstract class ActorStateWriteOperation : ActorWriteOperation
{
/// <summary>Prevent external derivations.</summary>
private protected ActorStateWriteOperation()
{
}
}
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
namespace Microsoft.Extensions.AI.Agents.Runtime;
@@ -8,6 +10,7 @@ namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Represents the type of an actor.
/// </summary>
[JsonConverter(typeof(Converter))]
public readonly partial struct ActorType : IEquatable<ActorType>
{
/// <summary>
@@ -60,9 +63,33 @@ public readonly partial struct ActorType : IEquatable<ActorType>
type is not null && TypeRegex().IsMatch(type);
#if NET
[GeneratedRegex("^[a-zA-Z_][a-zA-Z_0-9]*$")]
[GeneratedRegex("^[a-zA-Z_][a-zA-Z_:0-9]*$")]
private static partial Regex TypeRegex();
#else
private static Regex TypeRegex() => new("^[a-zA-Z_][a-zA-Z_0-9]*$", RegexOptions.Compiled);
private static Regex TypeRegex() => new("^[a-zA-Z_][a-zA-Z_:0-9:]*$", RegexOptions.Compiled);
#endif
/// <summary>
/// JSON converter for <see cref="ActorType"/>.
/// </summary>
public sealed class Converter : JsonConverter<ActorType>
{
/// <inheritdoc/>
public override ActorType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.String)
{
throw new JsonException("Expected string value for ActorType");
}
string? actorTypeString = reader.GetString() ?? throw new JsonException("ActorType cannot be null");
return new ActorType(actorTypeString);
}
/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, ActorType value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.Name);
}
}
}
@@ -0,0 +1,32 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Base class for all actor write operations that can modify actor state or messaging.
/// </summary>
/// <remarks>
/// This abstract class serves as the foundation for all actor write operation types.
/// Each concrete implementation represents a specific type of write operation,
/// such as modifying actor state or sending messages.
/// </remarks>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(SetValueOperation), "set_value")]
[JsonDerivedType(typeof(RemoveKeyOperation), "remove_key")]
[JsonDerivedType(typeof(UpdateRequestOperation), "update_request")]
[JsonDerivedType(typeof(SendRequestOperation), "send_request")]
public abstract class ActorWriteOperation
{
/// <summary>Prevent external derivations.</summary>
private protected ActorWriteOperation()
{
}
/// <summary>
/// Gets the type of the write operation.
/// </summary>
[JsonIgnore]
public abstract ActorWriteOperationType Type { get; }
}
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Represents a batch of write operations to be performed atomically on an actor.
/// </summary>
/// <param name="eTag">The ETag for optimistic concurrency control.</param>
/// <param name="operations">The collection of write operations to perform.</param>
public class ActorWriteOperationBatch(string eTag, IReadOnlyCollection<ActorWriteOperation> operations)
{
/// <summary>
/// Gets the collection of write operations to perform.
/// </summary>
[JsonPropertyName("operations")]
public IReadOnlyCollection<ActorWriteOperation> Operations { get; } = operations;
/// <summary>
/// Gets the ETag for optimistic concurrency control.
/// </summary>
[JsonPropertyName("etag")]
public string ETag { get; } = eTag;
}
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Specifies the type of actor write operation.
/// </summary>
public enum ActorWriteOperationType
{
/// <summary>
/// Represents a set key-value operation.
/// </summary>
[JsonStringEnumMemberName("set_value")]
SetValue,
/// <summary>
/// Represents a remove key operation.
/// </summary>
[JsonStringEnumMemberName("remove_key")]
RemoveKey,
/// <summary>
/// Represents a send request operation.
/// </summary>
[JsonStringEnumMemberName("send_request")]
SendRequest,
/// <summary>
/// Represents an update request operation.
/// </summary>
[JsonStringEnumMemberName("update_request")]
UpdateRequest
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Represents a request to read a value from the actor's state by its key.
/// </summary>
/// <param name="key">The key corresponding to the value to read from the actor's state.</param>
public class GetValueOperation(string key) : ActorStateReadOperation
{
/// <summary>
/// Gets the key corresponding to the value to read from the actor's state.
/// </summary>
[JsonPropertyName("key")]
public string Key { get; } = key;
/// <summary>
/// Gets the type of the read operation.
/// </summary>
public override ActorReadOperationType Type => ActorReadOperationType.GetValue;
}
@@ -0,0 +1,24 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Represents the result of a get value operation containing the retrieved value.
/// </summary>
/// <param name="value">The value retrieved from the actor's state, or null if not found.</param>
public class GetValueResult(JsonElement? value) : ActorReadResult
{
/// <summary>
/// Gets the value retrieved from the actor's state.
/// </summary>
[JsonPropertyName("value")]
public JsonElement? Value { get; } = value;
/// <summary>
/// Gets the type of the read result operation.
/// </summary>
public override ActorReadResultType Type => ActorReadResultType.GetValue;
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Extensions.AI.Agents.Runtime;
// Implemented by the Agent Framework (eg, Agent, Orchestration, Process, etc)
/// <summary>
/// Represents an actor in the actor system that can process messages and maintain state.
/// </summary>
public interface IActor : IAsyncDisposable
{
/// <summary>
/// Runs the actor.
/// When the value returned from this method completes, the actor is considered stopped.
/// IActor is expected to call IActorContext.WatchMessagesAsync() to receive messages.
/// </summary>
/// <param name="cancellationToken">A token to cancel the start operation.</param>
/// <returns>A task representing the start operation.</returns>
ValueTask RunAsync(CancellationToken cancellationToken);
}
@@ -0,0 +1,30 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Interface for sending requests to actors and managing responses.
/// </summary>
public interface IActorClient
{
/// <summary>
/// Submits a request to an actor and gets a handle for the response.
/// This method is idempotent: if the request is already in progress, it will return the existing response.
/// </summary>
/// <param name="request">The request to send to the actor.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A task representing the actor response handle.</returns>
ValueTask<ActorResponseHandle> SendRequestAsync(ActorRequest request, CancellationToken cancellationToken);
/// <summary>
/// Gets an already-running request by its identifier.
/// </summary>
/// <param name="actorId">The identifier of the actor processing the request.</param>
/// <param name="messageId">The unique identifier of the request message.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A task representing the actor response handle.</returns>
ValueTask<ActorResponseHandle> GetResponseAsync(ActorId actorId, string messageId, CancellationToken cancellationToken);
}
@@ -0,0 +1,18 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Builder interface for configuring actor types in the runtime.
/// </summary>
public interface IActorRuntimeBuilder
{
/// <summary>
/// Registers an actor type with its factory method.
/// </summary>
/// <param name="type">The actor type to register.</param>
/// <param name="activator">The factory method to create instances of the actor.</param>
void AddActorType(ActorType type, Func<IServiceProvider, IActorRuntimeContext, IActor> activator);
}
@@ -0,0 +1,51 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Provides the runtime context for an actor, enabling it to interact with the actor system.
/// </summary>
public interface IActorRuntimeContext
{
/// <summary>
/// Gets the identifier of the actor.
/// </summary>
ActorId ActorId { get; }
/// <summary>
/// Watches for incoming requests and responses in the actor's inbox and outbox.
/// </summary>
/// <param name="cancellationToken">A token to cancel the watch operation.</param>
/// <returns>An asynchronous enumerable of actor notifications.</returns>
IAsyncEnumerable<ActorMessage> WatchMessagesAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Performs a batch of write operations atomically.
/// </summary>
/// <param name="operations">The batch of write operations to perform.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A task representing the write response.</returns>
ValueTask<WriteResponse> WriteAsync(ActorWriteOperationBatch operations, CancellationToken cancellationToken = default);
/// <summary>
/// Performs a batch of read operations.
/// </summary>
/// <param name="operations">The batch of read operations to perform.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A task representing the read response.</returns>
ValueTask<ReadResponse> ReadAsync(ActorReadOperationBatch operations, CancellationToken cancellationToken = default);
/// <summary>
/// Reports progress updates for streaming responses.
/// The messageId must correspond to a non-terminated request in the actor's inbox (Status is Pending).
/// </summary>
/// <param name="messageId">The identifier of the message being updated.</param>
/// <param name="sequenceNumber">The sequence number for ordering progress updates.</param>
/// <param name="data">The progress data.</param>
void OnProgressUpdate(string messageId, int sequenceNumber, JsonElement data);
}
@@ -0,0 +1,32 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Interface for actor state storage operations, providing persistence for actor state data.
/// </summary>
public interface IActorStateStorage
{
/// <summary>
/// Writes state changes to the actor's persistent storage.
/// </summary>
/// <param name="actorId">The identifier of the actor whose state is being modified.</param>
/// <param name="operations">The collection of write operations to perform.</param>
/// <param name="etag">The expected ETag for optimistic concurrency control.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A task representing the write response with success status and updated ETag.</returns>
ValueTask<WriteResponse> WriteStateAsync(ActorId actorId, IReadOnlyCollection<ActorStateWriteOperation> operations, string etag, CancellationToken cancellationToken = default);
/// <summary>
/// Reads state data from the actor's persistent storage.
/// </summary>
/// <param name="actorId">The identifier of the actor whose state is being read.</param>
/// <param name="operations">The collection of read operations to perform.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A task representing the read response with results and current ETag.</returns>
ValueTask<ReadResponse> ReadStateAsync(ActorId actorId, IReadOnlyCollection<ActorStateReadOperation> operations, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,384 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Provides an in-memory implementation of <see cref="IActorStateStorage"/> for testing and development scenarios.
/// </summary>
/// <remarks>
/// <para>
/// This implementation stores all actor state in memory using concurrent dictionaries for thread safety.
/// State is not persisted across application restarts and is lost when the application terminates.
/// </para>
/// <para>
/// The implementation provides optimistic concurrency control using ETags. Each write operation must
/// provide the current ETag, and the operation will fail if the ETag has changed since the last read.
/// This ensures that concurrent modifications to the same actor state are handled correctly.
/// </para>
/// <para>
/// Supported operations:
/// <list type="bullet">
/// <item><description><see cref="SetValueOperation"/> - Sets a key-value pair in the actor's state</description></item>
/// <item><description><see cref="RemoveKeyOperation"/> - Removes a key from the actor's state</description></item>
/// <item><description><see cref="GetValueOperation"/> - Retrieves a value by key from the actor's state</description></item>
/// <item><description><see cref="ListKeysOperation"/> - Lists keys in the actor's state with optional prefix filtering</description></item>
/// </list>
/// </para>
/// <para>
/// This implementation is suitable for:
/// <list type="bullet">
/// <item><description>Unit testing scenarios</description></item>
/// <item><description>Development and prototyping</description></item>
/// <item><description>Single-process applications where persistence is not required</description></item>
/// </list>
/// </para>
/// <para>
/// For production scenarios requiring persistence, consider implementing a custom storage provider
/// that uses a database or other persistent storage mechanism.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// // Create storage instance
/// var storage = new InMemoryActorStateStorage();
/// var actorId = new ActorId("MyActor", "instance1");
///
/// // Write some state
/// var writeOps = new List&lt;ActorStateWriteOperation&gt;
/// {
/// new SetValueOperation("name", JsonSerializer.SerializeToElement("John")),
/// new SetValueOperation("age", JsonSerializer.SerializeToElement(30))
/// };
/// var writeResult = await storage.WriteStateAsync(actorId, writeOps, "0");
///
/// // Read the state back
/// var readOps = new List&lt;ActorStateReadOperation&gt;
/// {
/// new GetValueOperation("name"),
/// new ListKeysOperation(null), // List all keys
/// new ListKeysOperation(null, "prefix_") // List keys starting with "prefix_"
/// };
/// var readResult = await storage.ReadStateAsync(actorId, readOps);
/// </code>
/// </example>
public sealed class InMemoryActorStateStorage : IActorStateStorage
{
private static readonly ActivitySource ActivitySource = new("Microsoft.Extensions.AI.Agents.Runtime.Abstractions.InMemoryActorStateStorage");
private readonly ConcurrentDictionary<ActorId, ActorState> _actorStates = new();
private readonly object _lockObject = new();
private long _globalETagCounter = 0;
/// <summary>
/// Represents the internal state of an actor including its key-value pairs and ETag.
/// </summary>
private sealed class ActorState
{
public ConcurrentDictionary<string, JsonElement> Data { get; } = new();
public string ETag { get; set; } = "0";
}
/// <inheritdoc/>
public ValueTask<WriteResponse> WriteStateAsync(ActorId actorId, IReadOnlyCollection<ActorStateWriteOperation> operations, string etag, CancellationToken cancellationToken = default)
{
using var activity = ActivitySource.StartActivity("actor.state write");
if (operations is null)
{
throw new ArgumentNullException(nameof(operations));
}
if (etag is null)
{
throw new ArgumentNullException(nameof(etag));
}
cancellationToken.ThrowIfCancellationRequested();
// Set telemetry attributes
SetActorAttributes(activity, actorId);
SetStateAttributes(activity, "write", operations.Count, etag);
try
{
lock (this._lockObject)
{
var actorState = this._actorStates.GetOrAdd(actorId, _ => new ActorState());
// Check ETag for optimistic concurrency control
if (actorState.ETag != etag)
{
activity?.SetTag("state.success", false);
activity?.SetTag("error.type", "etag_mismatch");
activity?.SetStatus(ActivityStatusCode.Error, "ETag mismatch - concurrent modification detected");
// Return failure with current ETag
return new ValueTask<WriteResponse>(new WriteResponse(actorState.ETag, success: false));
}
// Apply all operations
var operationTypes = new List<string>();
foreach (var operation in operations)
{
switch (operation)
{
case SetValueOperation setValue:
actorState.Data[setValue.Key] = setValue.Value;
operationTypes.Add("set");
break;
case RemoveKeyOperation removeKey:
actorState.Data.TryRemove(removeKey.Key, out _);
operationTypes.Add("remove");
break;
default:
var errorMessage = $"Unsupported write operation type: {operation.GetType().Name}";
var exception = new InvalidOperationException(errorMessage);
SetErrorAttributes(activity, exception);
throw exception;
}
}
// Update ETag
var newETag = Interlocked.Increment(ref this._globalETagCounter).ToString();
actorState.ETag = newETag;
// Set success attributes
SetOperationStatus(activity, true);
activity?.SetTag("state.success", true);
activity?.SetTag("state.new_etag", newETag);
activity?.SetTag("state.operations", string.Join(",", operationTypes));
return new ValueTask<WriteResponse>(new WriteResponse(newETag, success: true));
}
}
catch (Exception ex)
{
SetErrorAttributes(activity, ex);
throw;
}
}
/// <inheritdoc/>
public ValueTask<ReadResponse> ReadStateAsync(ActorId actorId, IReadOnlyCollection<ActorStateReadOperation> operations, CancellationToken cancellationToken = default)
{
using var activity = ActivitySource.StartActivity("actor.state read");
if (operations is null)
{
throw new ArgumentNullException(nameof(operations));
}
cancellationToken.ThrowIfCancellationRequested();
// Set telemetry attributes
SetActorAttributes(activity, actorId);
SetStateAttributes(activity, "read", operations.Count);
try
{
var actorState = this._actorStates.GetOrAdd(actorId, _ => new ActorState());
var results = new List<ActorReadResult>();
var operationTypes = new List<string>();
foreach (var operation in operations)
{
switch (operation)
{
case GetValueOperation getValue:
var hasValue = actorState.Data.TryGetValue(getValue.Key, out var value);
results.Add(new GetValueResult(hasValue ? value : null));
operationTypes.Add($"get:{getValue.Key}");
break;
case ListKeysOperation listKeys:
var keys = actorState.Data.Keys.ToList();
// Filter keys by prefix if provided
if (!string.IsNullOrEmpty(listKeys.KeyPrefix))
{
keys = [.. keys.Where(key => key.StartsWith(listKeys.KeyPrefix, StringComparison.Ordinal))];
}
// Handle pagination if continuation token is provided
if (!string.IsNullOrEmpty(listKeys.ContinuationToken))
{
// For this simple implementation, we'll parse the continuation token as an index
if (int.TryParse(listKeys.ContinuationToken, out int startIndex) && startIndex < keys.Count)
{
keys = [.. keys.Skip(startIndex)];
}
else
{
keys = [];
}
}
// For simplicity, we'll return all keys without pagination
// In a real implementation, you might want to implement proper pagination
results.Add(new ListKeysResult(keys.AsReadOnly(), continuationToken: null));
operationTypes.Add($"list:{listKeys.KeyPrefix ?? "*"}");
break;
default:
var errorMessage = $"Unsupported read operation type: {operation.GetType().Name}";
var exception = new InvalidOperationException(errorMessage);
SetErrorAttributes(activity, exception);
throw exception;
}
}
// Set success attributes
SetOperationStatus(activity, true);
activity?.SetTag("state.etag", actorState.ETag);
activity?.SetTag("state.operations", string.Join(",", operationTypes));
activity?.SetTag("state.success", true);
return new ValueTask<ReadResponse>(new ReadResponse(actorState.ETag, results.AsReadOnly()));
}
catch (Exception ex)
{
SetErrorAttributes(activity, ex);
throw;
}
}
/// <summary>
/// Clears all stored actor state. This method is primarily intended for testing scenarios.
/// </summary>
public void Clear()
{
lock (this._lockObject)
{
this._actorStates.Clear();
Interlocked.Exchange(ref this._globalETagCounter, 0);
}
}
/// <summary>
/// Gets the current count of actors that have state stored.
/// </summary>
/// <returns>The number of actors with stored state.</returns>
public int ActorCount => this._actorStates.Count;
/// <summary>
/// Gets the current count of keys stored for a specific actor.
/// </summary>
/// <param name="actorId">The actor identifier.</param>
/// <returns>The number of keys stored for the specified actor, or 0 if the actor has no state.</returns>
public int GetKeyCount(ActorId actorId)
{
return this._actorStates.TryGetValue(actorId, out var state) ? state.Data.Count : 0;
}
/// <summary>
/// Gets the current ETag for a specific actor.
/// </summary>
/// <param name="actorId">The actor identifier.</param>
/// <returns>The current ETag for the specified actor, or "0" if the actor has no state.</returns>
public string GetETag(ActorId actorId)
{
return this._actorStates.TryGetValue(actorId, out var state) ? state.ETag : "0";
}
/// <summary>
/// Sets actor attributes on an activity.
/// </summary>
/// <param name="activity">The activity to set attributes on.</param>
/// <param name="actorId">The actor ID.</param>
private static void SetActorAttributes(Activity? activity, ActorId actorId)
{
if (activity == null)
{
return;
}
activity.SetTag("actor.id", actorId.ToString());
activity.SetTag("actor.type", actorId.Type.Name);
}
/// <summary>
/// Sets state operation attributes on an activity.
/// </summary>
/// <param name="activity">The activity to set attributes on.</param>
/// <param name="operationType">The type of state operation.</param>
/// <param name="operationCount">Optional count of operations.</param>
/// <param name="etag">Optional ETag value.</param>
private static void SetStateAttributes(Activity? activity, string operationType, int? operationCount = null, string? etag = null)
{
if (activity == null)
{
return;
}
activity.SetTag("state.operation.type", operationType);
if (operationCount.HasValue)
{
activity.SetTag("state.operation.count", operationCount.Value);
}
if (!string.IsNullOrEmpty(etag))
{
activity.SetTag("state.etag", etag);
}
}
/// <summary>
/// Sets success/failure status on an activity.
/// </summary>
/// <param name="activity">The activity to set status on.</param>
/// <param name="success">Whether the operation was successful.</param>
/// <param name="errorMessage">Optional error message for failures.</param>
private static void SetOperationStatus(Activity? activity, bool success, string? errorMessage = null)
{
if (activity == null)
{
return;
}
if (success)
{
activity.SetStatus(ActivityStatusCode.Ok);
}
else
{
activity.SetStatus(ActivityStatusCode.Error, errorMessage);
}
}
/// <summary>
/// Sets error attributes on an activity.
/// </summary>
/// <param name="activity">The activity to set error attributes on.</param>
/// <param name="exception">The exception that occurred.</param>
private static void SetErrorAttributes(Activity? activity, Exception exception)
{
if (activity == null)
{
return;
}
activity.SetTag("error.type", exception.GetType().Name);
activity.SetTag("error.message", exception.Message);
activity.SetStatus(ActivityStatusCode.Error, exception.Message);
// Add exception event
activity.AddEvent(new ActivityEvent("exception", DateTimeOffset.UtcNow, new ActivityTagsCollection
{
["error.type"] = exception.GetType().Name,
["error.message"] = exception.Message,
["error.stack_trace"] = exception.StackTrace
}));
}
}
@@ -0,0 +1,33 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Provides extension methods for JSON serialization with source generation support.
/// </summary>
internal static class JsonSerializerExtensions
{
/// <summary>
/// Gets the JsonTypeInfo for a type, preferring the one from options if available,
/// otherwise falling back to the source-generated context.
/// </summary>
/// <typeparam name="T">The type to get JsonTypeInfo for.</typeparam>
/// <param name="options">The JsonSerializerOptions to check first.</param>
/// <param name="fallbackContext">The fallback JsonSerializerContext to use if not found in options.</param>
/// <returns>The JsonTypeInfo for the requested type.</returns>
public static JsonTypeInfo<T> GetTypeInfo<T>(this JsonSerializerOptions options, JsonSerializerContext fallbackContext)
{
// Try to get from the options first (if a context is configured)
if (options.TypeInfoResolver?.GetTypeInfo(typeof(T), options) is JsonTypeInfo<T> typeInfo)
{
return typeInfo;
}
// Fall back to the provided source-generated context
return (JsonTypeInfo<T>)fallbackContext.GetTypeInfo(typeof(T))!;
}
}
@@ -0,0 +1,30 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Represents an operation to list keys from an actor's state, with optional pagination support.
/// </summary>
/// <param name="continuationToken">Optional token for pagination to continue listing from a previous operation.</param>
/// <param name="keyPrefix">Optional prefix to filter keys. Only keys starting with this prefix will be returned.</param>
public class ListKeysOperation(string? continuationToken, string? keyPrefix = null) : ActorStateReadOperation
{
/// <summary>
/// Gets the continuation token for pagination.
/// </summary>
[JsonPropertyName("continuationToken")]
public string? ContinuationToken { get; } = continuationToken;
/// <summary>
/// Gets the key prefix for filtering. Only keys starting with this prefix will be returned.
/// </summary>
[JsonPropertyName("keyPrefix")]
public string? KeyPrefix { get; } = keyPrefix;
/// <summary>
/// Gets the type of the read operation.
/// </summary>
public override ActorReadOperationType Type => ActorReadOperationType.ListKeys;
}
@@ -0,0 +1,31 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Represents the result of a list keys operation containing the found keys and optional continuation token.
/// </summary>
/// <param name="keys">The collection of keys found in the actor's state.</param>
/// <param name="continuationToken">Optional token for pagination to retrieve additional keys.</param>
public class ListKeysResult(IReadOnlyCollection<string> keys, string? continuationToken) : ActorReadResult
{
/// <summary>
/// Gets the collection of keys found in the actor's state.
/// </summary>
[JsonPropertyName("keys")]
public IReadOnlyCollection<string> Keys { get; } = keys;
/// <summary>
/// Gets the continuation token for pagination.
/// </summary>
[JsonPropertyName("continuationToken")]
public string? ContinuationToken { get; } = continuationToken;
/// <summary>
/// Gets the type of the read result operation.
/// </summary>
public override ActorReadResultType Type => ActorReadResultType.ListKeys;
}
@@ -5,12 +5,14 @@
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
<NoWarn>$(NoWarn);IDE1006;IDE0130</NoWarn>
<VersionSuffix>alpha</VersionSuffix>
<RootNamespace>Microsoft.Extensions.AI.Agents.Runtime</RootNamespace>
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectDiagnosticAttributesOnLegacy>true</InjectDiagnosticAttributesOnLegacy>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
@@ -23,6 +25,10 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageReference Include="System.Text.Json" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
<PackageReference Include="Microsoft.Bcl.HashCode" />
<PackageReference Include="System.Text.Json" />
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// The response of a read request for an actor.
/// </summary>
/// <param name="eTag">The actor's last-known ETag value.</param>
/// <param name="results">The ordered collection of results.</param>
public class ReadResponse(string eTag, IReadOnlyList<ActorReadResult> results)
{
/// <summary>
/// Gets the version of the state update.
/// </summary>
[JsonPropertyName("etag")]
public string ETag { get; } = eTag;
/// <summary>
/// Gets the ordered collection of read operation results.
/// </summary>
[JsonPropertyName("results")]
public IReadOnlyList<ActorReadResult> Results { get; } = results;
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Represents an operation to remove a key from an actor's state.
/// </summary>
/// <param name="Key">The key to remove from the actor's state.</param>
public sealed class RemoveKeyOperation(string Key) : ActorStateWriteOperation
{
/// <summary>
/// Gets the key for the state operation.
/// </summary>
[JsonPropertyName("key")]
public string Key { get; } = Key;
/// <summary>
/// Gets the type of the write operation.
/// </summary>
public override ActorWriteOperationType Type => ActorWriteOperationType.RemoveKey;
}
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Represents the status of a request in the actor system.
/// </summary>
public enum RequestStatus
{
/// <summary>
/// The request is pending and has not yet been processed.
/// </summary>
[JsonStringEnumMemberName("pending")]
Pending,
/// <summary>
/// The request has been completed successfully.
/// </summary>
[JsonStringEnumMemberName("completed")]
Completed,
/// <summary>
/// The request has failed.
/// </summary>
[JsonStringEnumMemberName("failed")]
Failed,
/// <summary>
/// The request was not found, possibly due to it being deleted or never existing.
/// </summary>
[JsonStringEnumMemberName("not_found")]
NotFound,
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Represents an operation to send a request message to another actor.
/// </summary>
/// <param name="Message">The request message to send.</param>
public sealed class SendRequestOperation(ActorRequestMessage Message) : ActorMessageWriteOperation
{
/// <summary>
/// Gets the message to send.
/// </summary>
[JsonPropertyName("message")]
public ActorRequestMessage Message { get; } = Message;
/// <summary>
/// Gets the type of the write operation.
/// </summary>
public override ActorWriteOperationType Type => ActorWriteOperationType.SendRequest;
}
@@ -0,0 +1,31 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Represents an operation to set a key-value pair in an actor's state.
/// </summary>
/// <param name="Key">The key to set in the actor's state.</param>
/// <param name="Value">The value to associate with the key.</param>
public sealed class SetValueOperation(string Key, JsonElement Value) : ActorStateWriteOperation
{
/// <summary>
/// Gets the key for the state operation.
/// </summary>
[JsonPropertyName("key")]
public string Key { get; } = Key;
/// <summary>
/// Gets the value for the state operation.
/// </summary>
[JsonPropertyName("value")]
public JsonElement Value { get; } = Value;
/// <summary>
/// Gets the type of the write operation.
/// </summary>
public override ActorWriteOperationType Type => ActorWriteOperationType.SetValue;
}
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Represents an operation to update the status of an incoming request, possibly with a result.
/// The MessageId must correspond to a non-terminated request in the actor's inbox (Status is Pending).
/// </summary>
/// <param name="MessageId">The identifier of the message to update.</param>
/// <param name="Status">The new status for the request.</param>
/// <param name="Data">The data associated with the status update (e.g., result for completed requests).</param>
public sealed class UpdateRequestOperation(string MessageId, RequestStatus Status, JsonElement Data) : ActorMessageWriteOperation
{
/// <summary>
/// Gets the identifier of the message to update.
/// </summary>
[JsonPropertyName("messageId")]
public string MessageId { get; } = MessageId;
/// <summary>
/// Gets the new status for the request.
/// </summary>
[JsonPropertyName("status")]
public RequestStatus Status { get; } = Status;
/// <summary>
/// Gets the data associated with the status update.
/// </summary>
[JsonPropertyName("data")]
public JsonElement Data { get; } = Data;
/// <summary>
/// Gets the type of the write operation.
/// </summary>
public override ActorWriteOperationType Type => ActorWriteOperationType.UpdateRequest;
}
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Represents the response of a write request for an actor.
/// </summary>
/// <param name="eTag">The actor's updated ETag value after the write operation.</param>
/// <param name="success">Whether the write operation was successful.</param>
public class WriteResponse(string eTag, bool success)
{
/// <summary>
/// Gets the version of the state update.
/// </summary>
[JsonPropertyName("etag")]
public string ETag { get; } = eTag;
/// <summary>
/// Whether the write operation was successful.
/// </summary>
/// <remarks>
/// If <c>false</c>, the write operation may have failed due to a concurrency conflict or other issue.
/// In either case the <see cref="ETag"/> property will contain the last known ETag value of the actor's state.
/// </remarks>
[JsonPropertyName("success")]
public bool Success { get; } = success;
}
@@ -0,0 +1,409 @@
// Copyright (c) Microsoft. All rights reserved.
using static Microsoft.Extensions.AI.Agents.Runtime.ActorRuntimeOpenTelemetryConsts;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Helper methods for setting common telemetry attributes on activities.
/// </summary>
internal static class ActivityExtensions
{
public const string ActorCreated = ActorRuntimeOpenTelemetryConsts.EventInfo.Names.ActorCreated;
public const string ActorStarted = ActorRuntimeOpenTelemetryConsts.EventInfo.Names.ActorStarted;
public const string MessageSent = ActorRuntimeOpenTelemetryConsts.EventInfo.Names.MessageSent;
public const string MessageReceived = ActorRuntimeOpenTelemetryConsts.EventInfo.Names.MessageReceived;
public const string RequestCompleted = ActorRuntimeOpenTelemetryConsts.EventInfo.Names.RequestCompleted;
// Re-export common status values for convenience
public const string Started = "started";
public const string Sent = "sent";
public const string Enqueued = "enqueued";
public const string Created = "created";
public const string Found = "found";
public const string HandleCreated = "handle_created";
/// <summary>
/// Sets common actor attributes on an activity.
/// </summary>
/// <param name="activity">The activity to set attributes on.</param>
/// <param name="actorId">The actor ID.</param>
/// <param name="operation">Optional operation name.</param>
public static void SetActorAttributes(this System.Diagnostics.Activity? activity, ActorId actorId, string? operation = null)
{
if (activity == null)
{
return;
}
activity.SetTag(ActorRuntimeOpenTelemetryConsts.Actor.Id, actorId.ToString());
activity.SetTag(ActorRuntimeOpenTelemetryConsts.Actor.Type, actorId.Type.Name);
activity.SetTag(ActorRuntimeOpenTelemetryConsts.Actor.RpcSystem, ActorRuntimeOpenTelemetryConsts.Actor.SystemName);
if (!string.IsNullOrEmpty(operation))
{
activity.SetTag(ActorRuntimeOpenTelemetryConsts.Actor.Operation, operation);
}
}
/// <summary>
/// Sets common message attributes on an activity.
/// </summary>
/// <param name="activity">The activity to set attributes on.</param>
/// <param name="messageId">The message ID.</param>
/// <param name="messageType">Optional message type.</param>
/// <param name="method">Optional message method.</param>
public static void SetMessageAttributes(this System.Diagnostics.Activity? activity, string messageId, string? messageType = null, string? method = null)
{
if (activity == null)
{
return;
}
activity.SetTag(Message.Id, messageId);
if (!string.IsNullOrEmpty(messageType))
{
activity.SetTag(Message.Type, messageType);
}
if (!string.IsNullOrEmpty(method))
{
activity.SetTag(Message.Method, method);
}
}
/// <summary>
/// Sets common request attributes on an activity.
/// </summary>
/// <param name="activity">The activity to set attributes on.</param>
/// <param name="requestId">The request ID.</param>
/// <param name="method">Optional request method.</param>
/// <param name="timeout">Optional timeout value.</param>
public static void SetRequestAttributes(this System.Diagnostics.Activity? activity, string requestId, string? method = null, System.TimeSpan? timeout = null)
{
if (activity == null)
{
return;
}
activity.SetTag(Request.Id, requestId);
if (!string.IsNullOrEmpty(method))
{
activity.SetTag(Request.Method, method);
}
if (timeout.HasValue)
{
activity.SetTag(Request.Timeout, timeout.Value.TotalMilliseconds);
}
}
/// <summary>
/// Sets common state operation attributes on an activity.
/// </summary>
/// <param name="activity">The activity to set attributes on.</param>
/// <param name="operationType">The type of state operation.</param>
/// <param name="operationCount">Optional count of operations.</param>
/// <param name="etag">Optional ETag value.</param>
public static void SetStateAttributes(this System.Diagnostics.Activity? activity, string operationType, int? operationCount = null, string? etag = null)
{
if (activity == null)
{
return;
}
activity.SetTag(State.OperationType, operationType);
if (operationCount.HasValue)
{
activity.SetTag(State.OperationCount, operationCount.Value);
}
if (!string.IsNullOrEmpty(etag))
{
activity.SetTag(State.ETag, etag);
}
}
/// <summary>
/// Sets success/failure status on an activity.
/// </summary>
/// <param name="activity">The activity to set status on.</param>
/// <param name="success">Whether the operation was successful.</param>
/// <param name="errorMessage">Optional error message for failures.</param>
public static void SetOperationStatus(this System.Diagnostics.Activity? activity, bool success, string? errorMessage = null)
{
if (activity == null)
{
return;
}
if (success)
{
activity.SetStatus(System.Diagnostics.ActivityStatusCode.Ok);
}
else
{
activity.SetStatus(System.Diagnostics.ActivityStatusCode.Error, errorMessage);
}
}
/// <summary>
/// Sets error attributes on an activity.
/// </summary>
/// <param name="activity">The activity to set error attributes on.</param>
/// <param name="exception">The exception that occurred.</param>
/// <param name="errorType">Optional custom error type.</param>
public static void SetErrorAttributes(this System.Diagnostics.Activity? activity, System.Exception exception, string? errorType = null)
{
if (activity == null)
{
return;
}
activity.SetTag(ErrorInfo.Type, errorType ?? exception.GetType().Name);
activity.SetTag(ErrorInfo.Message, exception.Message);
activity.SetStatus(System.Diagnostics.ActivityStatusCode.Error, exception.Message);
// Add exception event
activity.AddEvent(new System.Diagnostics.ActivityEvent("exception", System.DateTimeOffset.UtcNow, new System.Diagnostics.ActivityTagsCollection
{
[ErrorInfo.Type] = errorType ?? exception.GetType().Name,
[ErrorInfo.Message] = exception.Message,
[ErrorInfo.StackTrace] = exception.StackTrace
}));
}
/// <summary>
/// Sets RPC-style attributes for actor operations.
/// </summary>
/// <param name="activity">The activity to set attributes on.</param>
/// <param name="service">The RPC service name.</param>
/// <param name="method">The RPC method name.</param>
public static void SetRpcAttributes(this System.Diagnostics.Activity? activity, string service, string method)
{
if (activity == null)
{
return;
}
activity.SetTag(Actor.RpcSystem, Actor.SystemName);
activity.SetTag(Actor.RpcService, service);
activity.SetTag(Actor.RpcMethod, method);
}
/// <summary>
/// Sets up complete telemetry for actor retrieval/creation operations.
/// </summary>
/// <param name="activity">The activity to set attributes on.</param>
/// <param name="actorId">The actor ID.</param>
/// <param name="exists">Whether the actor already exists.</param>
/// <param name="started">Whether the actor was started.</param>
public static void SetupActorOperation(this System.Diagnostics.Activity? activity, ActorId actorId, bool? exists = null, bool? started = null)
{
if (activity == null)
{
return;
}
SetActorAttributes(activity, actorId);
SetRpcAttributes(activity, "ActorRuntime", "GetOrCreateActor");
if (exists.HasValue)
{
activity.SetTag(Actor.Exists, exists.Value);
}
if (started.HasValue)
{
activity.SetTag(Actor.Started, started.Value);
}
}
/// <summary>
/// Sets up complete telemetry for message operations.
/// </summary>
/// <param name="activity">The activity to set attributes on.</param>
/// <param name="actorId">The actor ID.</param>
/// <param name="messageId">The message ID.</param>
/// <param name="messageType">Optional message type.</param>
/// <param name="method">Optional message method.</param>
/// <param name="status">Optional message status.</param>
public static void SetupMessageOperation(this System.Diagnostics.Activity? activity, ActorId actorId, string messageId, string? messageType = null, string? method = null, string? status = null)
{
if (activity == null)
{
return;
}
SetActorAttributes(activity, actorId);
SetMessageAttributes(activity, messageId, messageType, method);
if (!string.IsNullOrEmpty(status))
{
activity.SetTag(Message.Status, status);
}
}
/// <summary>
/// Sets up complete telemetry for request operations.
/// </summary>
/// <param name="activity">The activity to set attributes on.</param>
/// <param name="actorId">The actor ID.</param>
/// <param name="requestId">The request ID.</param>
/// <param name="method">Optional request method.</param>
/// <param name="service">The RPC service name.</param>
/// <param name="rpcMethod">The RPC method name.</param>
/// <param name="timeout">Optional timeout value.</param>
public static void SetupRequestOperation(this System.Diagnostics.Activity? activity, ActorId actorId, string requestId, string? method = null, string service = "ActorClient", string rpcMethod = "SendRequest", System.TimeSpan? timeout = null)
{
if (activity == null)
{
return;
}
SetActorAttributes(activity, actorId);
SetRequestAttributes(activity, requestId, method, timeout);
SetRpcAttributes(activity, service, rpcMethod);
}
/// <summary>
/// Sets up complete telemetry for state operations.
/// </summary>
/// <param name="activity">The activity to set attributes on.</param>
/// <param name="actorId">The actor ID.</param>
/// <param name="operationType">The type of state operation.</param>
/// <param name="operationCount">Optional count of operations.</param>
/// <param name="etag">Optional ETag value.</param>
public static void SetupStateOperation(this System.Diagnostics.Activity? activity, ActorId actorId, string operationType, int? operationCount = null, string? etag = null)
{
if (activity == null)
{
return;
}
SetActorAttributes(activity, actorId);
SetStateAttributes(activity, operationType, operationCount, etag);
}
/// <summary>
/// Records successful completion of an operation with optional additional attributes.
/// </summary>
/// <param name="activity">The activity to update.</param>
/// <param name="additionalTags">Optional additional tags to set.</param>
public static void RecordSuccess(this System.Diagnostics.Activity? activity, params (string key, object? value)[] additionalTags)
{
if (activity == null)
{
return;
}
SetOperationStatus(activity, true);
foreach (var (key, value) in additionalTags)
{
activity.SetTag(key, value);
}
}
/// <summary>
/// Records failure of an operation with error details.
/// </summary>
/// <param name="activity">The activity to update.</param>
/// <param name="exception">The exception that occurred.</param>
/// <param name="errorType">Optional custom error type.</param>
/// <param name="additionalTags">Optional additional tags to set.</param>
public static void RecordFailure(this System.Diagnostics.Activity? activity, System.Exception exception, string? errorType = null, params (string key, object? value)[] additionalTags)
{
if (activity == null)
{
return;
}
SetErrorAttributes(activity, exception, errorType);
foreach (var (key, value) in additionalTags)
{
activity.SetTag(key, value);
}
}
/// <summary>
/// Adds an event with common actor context.
/// </summary>
/// <param name="activity">The activity to add the event to.</param>
/// <param name="eventName">The name of the event.</param>
/// <param name="actorId">The actor ID.</param>
/// <param name="additionalData">Optional additional event data.</param>
public static void AddActorEvent(this System.Diagnostics.Activity? activity, string eventName, ActorId actorId, params (string key, object? value)[] additionalData)
{
if (activity == null)
{
return;
}
var tags = new System.Diagnostics.ActivityTagsCollection
{
[Actor.Id] = actorId.ToString(),
[Actor.Type] = actorId.Type.Name
};
foreach (var (key, value) in additionalData)
{
tags[key] = value;
}
activity.AddEvent(new System.Diagnostics.ActivityEvent(eventName, System.DateTimeOffset.UtcNow, tags));
}
/// <summary>
/// Records successful completion and adds an event in a single terse call.
/// </summary>
/// <param name="activity">The activity to update.</param>
/// <param name="eventName">The name of the event to add.</param>
/// <param name="actorId">The actor ID for the event.</param>
/// <param name="statusTags">Status tags to set on the activity.</param>
/// <param name="eventData">Additional event data.</param>
public static void CompleteWithEvent(this System.Diagnostics.Activity? activity, string eventName, ActorId actorId, (string key, object? value)[] statusTags, params (string key, object? value)[] eventData)
{
if (activity == null)
{
return;
}
RecordSuccess(activity, statusTags);
AddActorEvent(activity, eventName, actorId, eventData);
}
/// <summary>
/// Complete with event - ultra-terse single-line calls.
/// </summary>
public static void Complete(this System.Diagnostics.Activity? activity, string @event, ActorId actor, string status, params (string, object?)[] data) =>
CompleteWithEvent(activity, @event, actor, [(Request.Status, status)], data);
/// <summary>
/// Complete with multiple status tags and event.
/// </summary>
public static void Complete(this System.Diagnostics.Activity? activity, string @event, ActorId actor, (string, object?)[] status, params (string, object?)[] data) =>
CompleteWithEvent(activity, @event, actor, status, data);
/// <summary>
/// Record success with single status.
/// </summary>
public static void Success(this System.Diagnostics.Activity? activity, string status) =>
RecordSuccess(activity, (Request.Status, status));
/// <summary>
/// Add actor event.
/// </summary>
public static void Event(this System.Diagnostics.Activity? activity, string @event, ActorId actor, params (string, object?)[] data) =>
AddActorEvent(activity, @event, actor, data);
/// <summary>
/// Record failure.
/// </summary>
public static void Fail(this System.Diagnostics.Activity? activity, System.Exception exception, string? status = null) =>
RecordFailure(activity, exception, null, status != null ? (Request.Status, status) : default);
}
@@ -0,0 +1,96 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Internal implementation of <see cref="IActorRuntimeBuilder"/> that manages actor type registrations
/// and their associated factory methods for the actor runtime system.
/// </summary>
internal sealed class ActorRuntimeBuilder : IActorRuntimeBuilder
{
private readonly IHostApplicationBuilder _builder;
/// <summary>
/// Gets the collection of registered actor types and their corresponding factory methods.
/// </summary>
/// <value>
/// A dictionary where keys are <see cref="ActorType"/> instances and values are factory functions
/// that create <see cref="IActor"/> instances given an <see cref="IServiceProvider"/> and <see cref="IActorRuntimeContext"/>.
/// </value>
public Dictionary<ActorType, Func<IServiceProvider, IActorRuntimeContext, IActor>> ActorFactories { get; } = new();
/// <summary>
/// Gets or creates an <see cref="ActorRuntimeBuilder"/> instance for the specified host application builder.
/// If an instance already exists in the service collection, it returns the existing instance.
/// Otherwise, it creates a new instance and registers it as a singleton service.
/// </summary>
/// <param name="builder">The host application builder to associate with the actor runtime builder.</param>
/// <returns>
/// An <see cref="ActorRuntimeBuilder"/> instance that can be used to configure actor types.
/// </returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> is null.</exception>
public static ActorRuntimeBuilder GetOrAdd(IHostApplicationBuilder builder)
{
Microsoft.Shared.Diagnostics.Throw.IfNull(builder);
var services = builder.Services;
var descriptor = services.FirstOrDefault(s => s.ImplementationInstance is ActorRuntimeBuilder);
if (descriptor?.ImplementationInstance is not ActorRuntimeBuilder instance)
{
instance = new ActorRuntimeBuilder(builder);
services.Add(ServiceDescriptor.Singleton(instance));
instance.ConfigureServices(services);
}
return instance;
}
/// <summary>
/// Initializes a new instance of the <see cref="ActorRuntimeBuilder"/> class.
/// </summary>
/// <param name="builder">The host application builder to associate with this actor runtime builder.</param>
private ActorRuntimeBuilder(IHostApplicationBuilder builder)
{
this._builder = builder;
}
/// <summary>
/// Registers an actor type with its factory method in the actor runtime.
/// </summary>
/// <param name="type">The actor type to register.</param>
/// <param name="activator">
/// The factory method that creates instances of the actor. This function receives an
/// <see cref="IServiceProvider"/> for dependency injection and an <see cref="IActorRuntimeContext"/>
/// for the actor's runtime context, and returns an <see cref="IActor"/> instance.
/// </param>
/// <exception cref="ArgumentException">
/// Thrown when an actor type with the same name is already registered.
/// </exception>
/// <remarks>
/// Each actor type can only be registered once. Attempting to register the same actor type
/// multiple times will result in an exception being thrown by the underlying dictionary.
/// </remarks>
public void AddActorType(ActorType type, Func<IServiceProvider, IActorRuntimeContext, IActor> activator)
{
this.ActorFactories.Add(type, activator);
}
private void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<IActorRuntimeBuilder>(this);
services.AddSingleton<IActorStateStorage, InMemoryActorStateStorage>();
services.AddSingleton<IActorClient, InProcessActorClient>();
services.AddSingleton<InProcessActorRuntime>(sp =>
{
var jsonSerializerOptions = sp.GetService<JsonSerializerOptions>() ?? new();
var actorStateStorage = sp.GetRequiredService<IActorStateStorage>();
return new InProcessActorRuntime(sp, this.ActorFactories, actorStateStorage, jsonSerializerOptions);
});
}
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.Hosting;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Provides extension methods for configuring actor runtime services in a host application.
/// </summary>
public static class ActorRuntimeHostingExtensions
{
/// <summary>
/// Adds actor runtime services to the specified host application builder.
/// </summary>
/// <param name="builder">The <see cref="IHostApplicationBuilder"/> to configure.</param>
/// <returns>An <see cref="IActorRuntimeBuilder"/> that can be used to further configure the actor runtime.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> is null.</exception>
public static IActorRuntimeBuilder AddActorRuntime(this IHostApplicationBuilder builder)
{
return ActorRuntimeBuilder.GetOrAdd(builder);
}
}
@@ -0,0 +1,16 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents.Runtime;
[JsonSourceGenerationOptions(
JsonSerializerDefaults.Web,
UseStringEnumConverter = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false)]
[JsonSerializable(typeof(string))]
internal sealed partial class ActorRuntimeJsonContext : JsonSerializerContext
{
}
@@ -0,0 +1,782 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Provides constants used by actor runtime telemetry services following OpenTelemetry semantic conventions.
/// Extends the base agent telemetry with runtime-specific attributes and operations.
/// </summary>
internal static class ActorRuntimeOpenTelemetryConsts
{
/// <summary>
/// The default source name for actor runtime telemetry.
/// </summary>
public const string DefaultSourceName = "Microsoft.Extensions.AI.Agents.Runtime";
/// <summary>
/// The default source name for in-process actor runtime telemetry.
/// </summary>
public const string InProcessSourceName = "Microsoft.Extensions.AI.Agents.Runtime.InProcess";
/// <summary>
/// The unit for count measurements.
/// </summary>
public const string CountUnit = "count";
/// <summary>
/// The unit for byte measurements.
/// </summary>
public const string ByteUnit = "byte";
/// <summary>
/// Constants for runtime operation names following OpenTelemetry semantic conventions.
/// These operations align with RPC and GenAI conventions where applicable.
/// </summary>
public static class Operations
{
/// <summary>
/// Actor creation operation.
/// </summary>
public const string CreateActor = "create_actor";
/// <summary>
/// Actor retrieval operation.
/// </summary>
public const string GetActor = "get_actor";
/// <summary>
/// Actor invocation operation (aligns with GenAI agent invoke conventions).
/// </summary>
public const string InvokeActor = "invoke_actor";
/// <summary>
/// Actor start operation.
/// </summary>
public const string StartActor = "start_actor";
/// <summary>
/// Actor stop operation.
/// </summary>
public const string StopActor = "stop_actor";
/// <summary>
/// Actor dispose operation.
/// </summary>
public const string DisposeActor = "dispose_actor";
/// <summary>
/// Message send operation.
/// </summary>
public const string SendMessage = "send_message";
/// <summary>
/// Message receive operation.
/// </summary>
public const string ReceiveMessage = "receive_message";
/// <summary>
/// Message process operation.
/// </summary>
public const string ProcessMessage = "process_message";
/// <summary>
/// Request send operation (follows RPC client pattern).
/// </summary>
public const string SendRequest = "send_request";
/// <summary>
/// Request receive operation (follows RPC server pattern).
/// </summary>
public const string ReceiveRequest = "receive_request";
/// <summary>
/// Request process operation.
/// </summary>
public const string ProcessRequest = "process_request";
/// <summary>
/// Response send operation.
/// </summary>
public const string SendResponse = "send_response";
/// <summary>
/// Response receive operation.
/// </summary>
public const string ReceiveResponse = "receive_response";
/// <summary>
/// Progress update operation.
/// </summary>
public const string ProgressUpdate = "progress_update";
/// <summary>
/// State read operation.
/// </summary>
public const string StateRead = "state_read";
/// <summary>
/// State write operation.
/// </summary>
public const string StateWrite = "state_write";
/// <summary>
/// Actor runtime initialization operation.
/// </summary>
public const string InitializeRuntime = "initialize_runtime";
/// <summary>
/// Actor runtime shutdown operation.
/// </summary>
public const string ShutdownRuntime = "shutdown_runtime";
}
/// <summary>
/// Constants for span naming patterns following OpenTelemetry semantic conventions.
/// Span names should be low-cardinality and follow the pattern: {namespace} {operation_name} [{target}]
/// </summary>
public static class SpanNames
{
/// <summary>
/// Base pattern for actor operations: "actor {operation}"
/// </summary>
public const string ActorOperationPattern = "actor {0}";
/// <summary>
/// Pattern for actor operations with specific actor type: "actor {operation} {actor_type}"
/// </summary>
public const string ActorOperationWithTypePattern = "actor {0} {1}";
/// <summary>
/// Pattern for message operations: "actor.message {operation}"
/// </summary>
public const string MessageOperationPattern = "actor.message {0}";
/// <summary>
/// Pattern for request operations: "actor.request {operation}"
/// </summary>
public const string RequestOperationPattern = "actor.request {0}";
/// <summary>
/// Pattern for state operations: "actor.state {operation}"
/// </summary>
public const string StateOperationPattern = "actor.state {0}";
/// <summary>
/// Pattern for runtime operations: "actor.runtime {operation}"
/// </summary>
public const string RuntimeOperationPattern = "actor.runtime {0}";
/// <summary>
/// Formats a span name for actor operations.
/// </summary>
/// <param name="operation">The operation name</param>
/// <returns>Formatted span name</returns>
public static string FormatActorOperation(string operation) => $"actor {operation}";
/// <summary>
/// Formats a span name for actor operations with actor type.
/// </summary>
/// <param name="operation">The operation name</param>
/// <param name="actorType">The actor type</param>
/// <returns>Formatted span name</returns>
public static string FormatActorOperationWithType(string operation, string actorType) => $"actor {operation} {actorType}";
/// <summary>
/// Formats a span name for message operations.
/// </summary>
/// <param name="operation">The operation name</param>
/// <returns>Formatted span name</returns>
public static string FormatMessageOperation(string operation) => $"actor.message {operation}";
/// <summary>
/// Formats a span name for request operations.
/// </summary>
/// <param name="operation">The operation name</param>
/// <returns>Formatted span name</returns>
public static string FormatRequestOperation(string operation) => $"actor.request {operation}";
/// <summary>
/// Formats a span name for state operations.
/// </summary>
/// <param name="operation">The operation name</param>
/// <returns>Formatted span name</returns>
public static string FormatStateOperation(string operation) => $"actor.state {operation}";
/// <summary>
/// Formats a span name for runtime operations.
/// </summary>
/// <param name="operation">The operation name</param>
/// <returns>Formatted span name</returns>
public static string FormatRuntimeOperation(string operation) => $"actor.runtime {operation}";
}
/// <summary>
/// Constants for actor-related telemetry attributes.
/// </summary>
public static class Actor
{
/// <summary>
/// The attribute name for the actor ID.
/// </summary>
public const string Id = "actor.id";
/// <summary>
/// The attribute name for the actor type.
/// </summary>
public const string Type = "actor.type";
/// <summary>
/// The attribute name for the actor key.
/// </summary>
public const string Key = "actor.key";
/// <summary>
/// The attribute name for the actor operation.
/// </summary>
public const string Operation = "actor.operation";
/// <summary>
/// The attribute name for whether the actor exists.
/// </summary>
public const string Exists = "actor.exists";
/// <summary>
/// The attribute name for whether the actor was started.
/// </summary>
public const string Started = "actor.started";
/// <summary>
/// The attribute name for the actor runtime type.
/// </summary>
public const string RuntimeType = "actor.runtime.type";
/// <summary>
/// The attribute name for the actor state.
/// </summary>
public const string State = "actor.state";
/// <summary>
/// RPC system identifier for actor runtime (follows RPC semantic conventions).
/// </summary>
public const string RpcSystem = "rpc.system";
/// <summary>
/// RPC service name for actor runtime (follows RPC semantic conventions).
/// </summary>
public const string RpcService = "rpc.service";
/// <summary>
/// RPC method name for actor runtime (follows RPC semantic conventions).
/// </summary>
public const string RpcMethod = "rpc.method";
/// <summary>
/// The system name for actor runtime operations.
/// </summary>
public const string SystemName = "actor_runtime";
/// <summary>
/// Constants for actor lifecycle attributes.
/// </summary>
public static class Lifecycle
{
/// <summary>
/// The attribute name for the actor creation time.
/// </summary>
public const string CreatedAt = "actor.lifecycle.created_at";
/// <summary>
/// The attribute name for the actor start time.
/// </summary>
public const string StartedAt = "actor.lifecycle.started_at";
/// <summary>
/// The attribute name for the actor stop time.
/// </summary>
public const string StoppedAt = "actor.lifecycle.stopped_at";
/// <summary>
/// The attribute name for the actor uptime.
/// </summary>
public const string Uptime = "actor.lifecycle.uptime";
}
/// <summary>
/// Constants for actor context attributes.
/// </summary>
public static class Context
{
/// <summary>
/// The attribute name for the actor context type.
/// </summary>
public const string Type = "actor.context.type";
/// <summary>
/// The attribute name for the actor context status.
/// </summary>
public const string Status = "actor.context.status";
/// <summary>
/// The attribute name for the actor context error.
/// </summary>
public const string Error = "actor.context.error";
}
/// <summary>
/// Constants for actor performance metrics.
/// </summary>
public static class Performance
{
/// <summary>
/// The attribute name for messages processed count.
/// </summary>
public const string MessagesProcessed = "actor.performance.messages_processed";
/// <summary>
/// The attribute name for requests processed count.
/// </summary>
public const string RequestsProcessed = "actor.performance.requests_processed";
/// <summary>
/// The attribute name for processing time.
/// </summary>
public const string ProcessingTime = "actor.performance.processing_time";
/// <summary>
/// The attribute name for queue size.
/// </summary>
public const string QueueSize = "actor.performance.queue_size";
}
}
/// <summary>
/// Constants for message-related telemetry attributes.
/// </summary>
public static class Message
{
/// <summary>
/// The attribute name for the message ID.
/// </summary>
public const string Id = "message.id";
/// <summary>
/// The attribute name for the message type.
/// </summary>
public const string Type = "message.type";
/// <summary>
/// The attribute name for the message method.
/// </summary>
public const string Method = "message.method";
/// <summary>
/// The attribute name for the message size in bytes.
/// </summary>
public const string Size = "message.size";
/// <summary>
/// The attribute name for the message timestamp.
/// </summary>
public const string Timestamp = "message.timestamp";
/// <summary>
/// The attribute name for the message sender.
/// </summary>
public const string Sender = "message.sender";
/// <summary>
/// The attribute name for the message recipient.
/// </summary>
public const string Recipient = "message.recipient";
/// <summary>
/// The attribute name for the message status.
/// </summary>
public const string Status = "message.status";
/// <summary>
/// The attribute name for the message sequence number.
/// </summary>
public const string SequenceNumber = "message.sequence_number";
/// <summary>
/// Constants for message processing attributes.
/// </summary>
public static class Processing
{
/// <summary>
/// The attribute name for processing start time.
/// </summary>
public const string StartTime = "message.processing.start_time";
/// <summary>
/// The attribute name for processing end time.
/// </summary>
public const string EndTime = "message.processing.end_time";
/// <summary>
/// The attribute name for processing duration.
/// </summary>
public const string Duration = "message.processing.duration";
/// <summary>
/// The attribute name for processing status.
/// </summary>
public const string Status = "message.processing.status";
/// <summary>
/// The attribute name for processing error.
/// </summary>
public const string Error = "message.processing.error";
}
}
/// <summary>
/// Constants for request-related telemetry attributes.
/// </summary>
public static class Request
{
/// <summary>
/// The attribute name for the request ID.
/// </summary>
public const string Id = "request.id";
/// <summary>
/// The attribute name for the request method.
/// </summary>
public const string Method = "request.method";
/// <summary>
/// The attribute name for the request status.
/// </summary>
public const string Status = "request.status";
/// <summary>
/// The attribute name for the request timeout.
/// </summary>
public const string Timeout = "request.timeout";
/// <summary>
/// The attribute name for whether the request was cancelled.
/// </summary>
public const string Cancelled = "request.cancelled";
/// <summary>
/// The attribute name for the request retry count.
/// </summary>
public const string RetryCount = "request.retry_count";
}
/// <summary>
/// Constants for response-related telemetry attributes.
/// </summary>
public static class Response
{
/// <summary>
/// The attribute name for the response ID.
/// </summary>
public const string Id = "response.id";
/// <summary>
/// The attribute name for the response status.
/// </summary>
public const string Status = "response.status";
/// <summary>
/// The attribute name for the response size.
/// </summary>
public const string Size = "response.size";
/// <summary>
/// The attribute name for the response type.
/// </summary>
public const string Type = "response.type";
}
/// <summary>
/// Constants for state-related telemetry attributes.
/// </summary>
public static class State
{
/// <summary>
/// The attribute name for the state operation type.
/// </summary>
public const string OperationType = "state.operation.type";
/// <summary>
/// The attribute name for the state operation count.
/// </summary>
public const string OperationCount = "state.operation.count";
/// <summary>
/// The attribute name for the state result count.
/// </summary>
public const string ResultCount = "state.result.count";
/// <summary>
/// The attribute name for the state operation success.
/// </summary>
public const string Success = "state.success";
/// <summary>
/// The attribute name for the state ETag.
/// </summary>
public const string ETag = "state.etag";
/// <summary>
/// The attribute name for the state size.
/// </summary>
public const string Size = "state.size";
/// <summary>
/// The attribute name for the state key.
/// </summary>
public const string Key = "state.key";
}
/// <summary>
/// Constants for runtime client metrics.
/// </summary>
public static class Client
{
/// <summary>
/// Constants for operation duration metrics.
/// </summary>
public static class OperationDuration
{
/// <summary>
/// The description for the operation duration metric.
/// </summary>
public const string Description = "Measures the duration of actor runtime operations";
/// <summary>
/// The name for the operation duration metric.
/// </summary>
public const string Name = "actor.runtime.client.operation.duration";
/// <summary>
/// The explicit bucket boundaries for the operation duration histogram.
/// </summary>
public static readonly double[] ExplicitBucketBoundaries = [0.001, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 20.0, 50.0];
}
/// <summary>
/// Constants for message count metrics.
/// </summary>
public static class MessageCount
{
/// <summary>
/// The description for the message count metric.
/// </summary>
public const string Description = "Measures the number of messages processed by actors";
/// <summary>
/// The name for the message count metric.
/// </summary>
public const string Name = "actor.runtime.client.message.count";
}
/// <summary>
/// Constants for request count metrics.
/// </summary>
public static class RequestCount
{
/// <summary>
/// The description for the request count metric.
/// </summary>
public const string Description = "Measures the number of requests processed by actors";
/// <summary>
/// The name for the request count metric.
/// </summary>
public const string Name = "actor.runtime.client.request.count";
}
/// <summary>
/// Constants for actor count metrics.
/// </summary>
public static class ActorCount
{
/// <summary>
/// The description for the actor count metric.
/// </summary>
public const string Description = "Measures the number of active actors";
/// <summary>
/// The name for the actor count metric.
/// </summary>
public const string Name = "actor.runtime.client.actor.count";
}
/// <summary>
/// Constants for queue size metrics.
/// </summary>
public static class QueueSize
{
/// <summary>
/// The description for the queue size metric.
/// </summary>
public const string Description = "Measures the size of actor message queues";
/// <summary>
/// The name for the queue size metric.
/// </summary>
public const string Name = "actor.runtime.client.queue.size";
/// <summary>
/// The explicit bucket boundaries for the queue size histogram.
/// </summary>
public static readonly int[] ExplicitBucketBoundaries = [0, 1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000];
}
/// <summary>
/// Constants for state operation metrics.
/// </summary>
public static class StateOperations
{
/// <summary>
/// The description for the state operations metric.
/// </summary>
public const string Description = "Measures the number of state operations";
/// <summary>
/// The name for the state operations metric.
/// </summary>
public const string Name = "actor.runtime.client.state.operations";
}
}
/// <summary>
/// Constants for error attributes.
/// </summary>
public static class ErrorInfo
{
/// <summary>
/// The attribute name for the error type (follows OpenTelemetry error conventions).
/// </summary>
public const string Type = "error.type";
/// <summary>
/// The attribute name for the error message.
/// </summary>
public const string Message = "error.message";
/// <summary>
/// The attribute name for the error stack trace.
/// </summary>
public const string StackTrace = "error.stack_trace";
/// <summary>
/// Well-known error type for unknown errors.
/// </summary>
public const string TypeOther = "_OTHER";
/// <summary>
/// Well-known error types for actor runtime operations.
/// </summary>
public static class Types
{
/// <summary>
/// Actor not found error.
/// </summary>
public const string ActorNotFound = "actor_not_found";
/// <summary>
/// Actor already exists error.
/// </summary>
public const string ActorAlreadyExists = "actor_already_exists";
/// <summary>
/// Message delivery failure.
/// </summary>
public const string MessageDeliveryFailure = "message_delivery_failure";
/// <summary>
/// Request timeout error.
/// </summary>
public const string RequestTimeout = "request_timeout";
/// <summary>
/// State operation failure.
/// </summary>
public const string StateOperationFailure = "state_operation_failure";
/// <summary>
/// Runtime initialization failure.
/// </summary>
public const string RuntimeInitializationFailure = "runtime_initialization_failure";
}
}
/// <summary>
/// Constants for event attributes and well-known event names.
/// </summary>
public static class EventInfo
{
/// <summary>
/// The attribute name for the event name.
/// </summary>
public const string Name = "event.name";
/// <summary>
/// The attribute name for the event data.
/// </summary>
public const string Data = "event.data";
/// <summary>
/// The attribute name for the event timestamp.
/// </summary>
public const string Timestamp = "event.timestamp";
/// <summary>
/// Well-known event names for actor runtime operations.
/// </summary>
public static class Names
{
/// <summary>
/// Actor created event.
/// </summary>
public const string ActorCreated = "actor.created";
/// <summary>
/// Actor started event.
/// </summary>
public const string ActorStarted = "actor.started";
/// <summary>
/// Actor stopped event.
/// </summary>
public const string ActorStopped = "actor.stopped";
/// <summary>
/// Message sent event.
/// </summary>
public const string MessageSent = "actor.message.sent";
/// <summary>
/// Message received event.
/// </summary>
public const string MessageReceived = "actor.message.received";
/// <summary>
/// Request completed event.
/// </summary>
public const string RequestCompleted = "actor.request.completed";
/// <summary>
/// State updated event.
/// </summary>
public const string StateUpdated = "actor.state.updated";
/// <summary>
/// Runtime initialized event.
/// </summary>
public const string RuntimeInitialized = "actor.runtime.initialized";
/// <summary>
/// Runtime shutdown event.
/// </summary>
public const string RuntimeShutdown = "actor.runtime.shutdown";
}
}
}
@@ -0,0 +1,131 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.Logging;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// High-performance logging messages using LoggerMessage source generator for InProcessActorContext.
/// </summary>
internal static partial class Log
{
// Actor context lifecycle logging
[LoggerMessage(
Level = LogLevel.Information,
Message = "Actor context created: ActorId={ActorId}")]
public static partial void ActorContextCreated(ILogger logger, string actorId);
[LoggerMessage(
Level = LogLevel.Information,
Message = "Actor context starting: ActorId={ActorId}")]
public static partial void ActorContextStarting(ILogger logger, string actorId);
[LoggerMessage(
Level = LogLevel.Information,
Message = "Actor context started: ActorId={ActorId}")]
public static partial void ActorContextStarted(ILogger logger, string actorId);
[LoggerMessage(
Level = LogLevel.Information,
Message = "Actor context disposing: ActorId={ActorId}")]
public static partial void ActorContextDisposing(ILogger logger, string actorId);
[LoggerMessage(
Level = LogLevel.Information,
Message = "Actor context disposed: ActorId={ActorId}")]
public static partial void ActorContextDisposed(ILogger logger, string actorId);
// Message handling logging
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Message enqueued: ActorId={ActorId}, MessageId={MessageId}, Type={MessageType}")]
public static partial void MessageEnqueued(ILogger logger, string actorId, string messageId, string messageType);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Message yielded: ActorId={ActorId}, MessageId={MessageId}, Type={MessageType}, Count={MessageCount}")]
public static partial void MessageYielded(ILogger logger, string actorId, string messageId, string messageType, int messageCount);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Watch messages started: ActorId={ActorId}")]
public static partial void WatchMessagesStarted(ILogger logger, string actorId);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Watch messages completed: ActorId={ActorId}, TotalMessages={MessageCount}")]
public static partial void WatchMessagesCompleted(ILogger logger, string actorId, int messageCount);
// Request handling logging
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Send request started: ActorId={ActorId}, MessageId={MessageId}")]
public static partial void SendRequestStarted(ILogger logger, string actorId, string messageId);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Request message created: ActorId={ActorId}, MessageId={MessageId}, Method={Method}")]
public static partial void RequestMessageCreated(ILogger logger, string actorId, string messageId, string method);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Request message found in inbox: ActorId={ActorId}, MessageId={MessageId}")]
public static partial void RequestMessageFound(ILogger logger, string actorId, string messageId);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Response handle created: ActorId={ActorId}, MessageId={MessageId}")]
public static partial void ResponseHandleCreated(ILogger logger, string actorId, string messageId);
// Progress update logging
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Progress update received: ActorId={ActorId}, MessageId={MessageId}, SequenceNumber={SequenceNumber}")]
public static partial void ProgressUpdateReceived(ILogger logger, string actorId, string messageId, int sequenceNumber);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Progress update published: ActorId={ActorId}, MessageId={MessageId}")]
public static partial void ProgressUpdatePublished(ILogger logger, string actorId, string messageId);
[LoggerMessage(
Level = LogLevel.Error,
Message = "Progress update failed: ActorId={ActorId}, MessageId={MessageId}, Reason={Reason}")]
public static partial void ProgressUpdateFailed(ILogger logger, string actorId, string messageId, string reason);
// Storage operation logging
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Read operation started: ActorId={ActorId}, OperationCount={OperationCount}")]
public static partial void ReadOperationStarted(ILogger logger, string actorId, int operationCount);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Read operation completed: ActorId={ActorId}, ResultCount={ResultCount}")]
public static partial void ReadOperationCompleted(ILogger logger, string actorId, int resultCount);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Write operation started: ActorId={ActorId}, OperationCount={OperationCount}")]
public static partial void WriteOperationStarted(ILogger logger, string actorId, int operationCount);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Write operation completed: ActorId={ActorId}, Success={Success}")]
public static partial void WriteOperationCompleted(ILogger logger, string actorId, bool success);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Send request operation encountered: ActorId={ActorId}")]
public static partial void SendRequestOperationEncountered(ILogger logger, string actorId);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Update request operation processing: ActorId={ActorId}, MessageId={MessageId}")]
public static partial void UpdateRequestOperationProcessing(ILogger logger, string actorId, string messageId);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Operation processing completed: ActorId={ActorId}, ProcessedCount={ProcessedCount}")]
public static partial void OperationProcessingCompleted(ILogger logger, string actorId, int processedCount);
}
@@ -0,0 +1,426 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using static Microsoft.Extensions.AI.Agents.Runtime.ActivityExtensions;
using Tel = Microsoft.Extensions.AI.Agents.Runtime.ActorRuntimeOpenTelemetryConsts;
namespace Microsoft.Extensions.AI.Agents.Runtime;
internal sealed class InProcessActorContext : IActorRuntimeContext, IAsyncDisposable, IDisposable
{
private static readonly ActivitySource ActivitySource = new(ActorRuntimeOpenTelemetryConsts.InProcessSourceName);
private readonly CancellationTokenSource _cts = new();
private readonly Channel<ActorMessage> _pendingMessages = Channel.CreateUnbounded<ActorMessage>();
private readonly object _lock = new();
private readonly Dictionary<string, ActorInboxEntry> _inbox = [];
private readonly InProcessActorRuntime _runtime;
private readonly IActor _actorInstance;
private readonly ILogger<InProcessActorContext> _logger;
private Task? _actorRunTask;
public InProcessActorContext(
ActorId ActorId,
InProcessActorRuntime runtime,
Func<IServiceProvider, IActorRuntimeContext, IActor> actorFactory)
{
this._runtime = runtime;
this.ActorId = ActorId;
this._logger = runtime.Services.GetRequiredService<ILogger<InProcessActorContext>>();
this._actorInstance = actorFactory(runtime.Services, this);
Log.ActorContextCreated(this._logger, this.ActorId.ToString());
}
public ActorId ActorId { get; }
private IActorStateStorage Storage => this._runtime.Storage;
public void Start()
{
using var activity = ActivitySource.StartActivity(
ActorRuntimeOpenTelemetryConsts.SpanNames.FormatActorOperation(ActorRuntimeOpenTelemetryConsts.Operations.StartActor));
activity.SetActorAttributes(this.ActorId, "start");
try
{
Log.ActorContextStarting(this._logger, this.ActorId.ToString());
this._actorRunTask = this._actorInstance.RunAsync(this._cts.Token).AsTask();
Log.ActorContextStarted(this._logger, this.ActorId.ToString());
activity.Complete(ActorStarted, this.ActorId, [(Tel.Actor.Started, true)]);
}
catch (Exception ex)
{
activity.Fail(ex);
throw;
}
}
public void EnqueueMessage(ActorMessage message)
{
using var activity = ActivitySource.StartActivity(
ActorRuntimeOpenTelemetryConsts.SpanNames.FormatMessageOperation(ActorRuntimeOpenTelemetryConsts.Operations.ReceiveMessage));
var messageId = message switch
{
ActorRequestMessage requestMessage => requestMessage.MessageId,
ActorResponseMessage responseMessage => responseMessage.MessageId,
_ => "unknown"
};
// Set message tracing attributes
activity.SetActorAttributes(this.ActorId);
activity.SetMessageAttributes(messageId, message.Type.ToString());
try
{
Log.MessageEnqueued(this._logger, this.ActorId.ToString(), messageId, message.Type.ToString());
this._pendingMessages.Writer.TryWrite(message);
activity.Complete(MessageReceived, this.ActorId, Enqueued,
(Tel.Message.Id, messageId), (Tel.Message.Type, message.Type.ToString()));
}
catch (Exception ex)
{
activity.RecordFailure(ex, null, (ActorRuntimeOpenTelemetryConsts.Message.Status, "failed"));
throw;
}
}
public ActorResponseHandle SendRequest(ActorRequest request)
{
using var activity = ActivitySource.StartActivity(
ActorRuntimeOpenTelemetryConsts.SpanNames.FormatRequestOperation(ActorRuntimeOpenTelemetryConsts.Operations.ProcessRequest));
activity.SetupRequestOperation(this.ActorId, request.MessageId, request.Method, "ActorContext", "SendRequest");
Log.SendRequestStarted(this._logger, this.ActorId.ToString(), request.MessageId);
try
{
lock (this._lock)
{
string requestStatus;
if (!this._inbox.TryGetValue(request.MessageId, out var entry))
{
var requestMessage = new ActorRequestMessage(request.MessageId)
{
Method = request.Method,
Params = request.Params
};
entry = this._inbox[request.MessageId] = new(requestMessage);
this._pendingMessages.Writer.TryWrite(requestMessage);
Log.RequestMessageCreated(this._logger, this.ActorId.ToString(), request.MessageId, request.Method);
requestStatus = "created";
}
else
{
Log.RequestMessageFound(this._logger, this.ActorId.ToString(), request.MessageId);
requestStatus = "found";
}
var handle = new InProcessActorResponseHandle(this, entry);
Log.ResponseHandleCreated(this._logger, this.ActorId.ToString(), request.MessageId);
activity.Complete(RequestCompleted, this.ActorId, [(Tel.Request.Status, requestStatus), (Tel.Response.Status, HandleCreated)],
(Tel.Message.Id, request.MessageId), (Tel.Message.Method, request.Method));
return handle;
}
}
catch (Exception ex)
{
activity.RecordFailure(ex, null, (ActorRuntimeOpenTelemetryConsts.Request.Status, "failed"));
throw;
}
}
public void OnProgressUpdate(string messageId, int sequenceNumber, JsonElement data)
{
using var activity = ActivitySource.StartActivity(
ActorRuntimeOpenTelemetryConsts.SpanNames.FormatActorOperation(ActorRuntimeOpenTelemetryConsts.Operations.ProgressUpdate));
activity.SetActorAttributes(this.ActorId);
activity.SetMessageAttributes(messageId);
activity?.SetTag(ActorRuntimeOpenTelemetryConsts.Message.SequenceNumber, sequenceNumber);
try
{
Log.ProgressUpdateReceived(this._logger, this.ActorId.ToString(), messageId, sequenceNumber);
var update = new UpdateRequestOperation(messageId, RequestStatus.Pending, data);
this.PostRequestUpdate(update);
activity.RecordSuccess((ActorRuntimeOpenTelemetryConsts.Message.Status, "processed"));
}
catch (Exception ex)
{
activity.RecordFailure(ex);
throw;
}
}
private void PostRequestUpdate(UpdateRequestOperation update)
{
lock (this._lock)
{
if (!this._inbox.TryGetValue(update.MessageId, out var entry))
{
Log.ProgressUpdateFailed(this._logger, this.ActorId.ToString(), update.MessageId, "Message not found in inbox");
throw new InvalidOperationException($"Message with id '{update.MessageId}' not found while publishing update.");
}
entry.PostUpdate(update);
if (update.Status is RequestStatus.Completed or RequestStatus.Failed)
{
entry.SetResponse(new ActorResponseMessage(update.MessageId)
{
SenderId = this.ActorId,
Status = update.Status,
Data = update.Data
});
}
Log.ProgressUpdatePublished(this._logger, this.ActorId.ToString(), update.MessageId);
}
}
public async ValueTask<ReadResponse> ReadAsync(ActorReadOperationBatch operations, CancellationToken cancellationToken = default)
{
Log.ReadOperationStarted(this._logger, this.ActorId.ToString(), operations.Operations.Count);
var result = await this.Storage.ReadStateAsync(
this.ActorId,
[.. operations.Operations.OfType<ActorStateReadOperation>()],
cancellationToken).ConfigureAwait(false);
Log.ReadOperationCompleted(this._logger, this.ActorId.ToString(), result.Results.Count);
return result;
}
public async IAsyncEnumerable<ActorMessage> WatchMessagesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
Log.WatchMessagesStarted(this._logger, this.ActorId.ToString());
// TODO: Yield all pending requests - this likely requires reading the inbox from storage.
// TODO: Yield all responses
// TODO: Yield all updates
var messageCount = 0;
await foreach (var message in this._pendingMessages.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
messageCount++;
var messageId = message switch
{
ActorRequestMessage requestMessage => requestMessage.MessageId,
ActorResponseMessage responseMessage => responseMessage.MessageId,
_ => "unknown"
};
Log.MessageYielded(this._logger, this.ActorId.ToString(), messageId, message.Type.ToString(), messageCount);
yield return message;
}
Log.WatchMessagesCompleted(this._logger, this.ActorId.ToString(), messageCount);
}
public async ValueTask<WriteResponse> WriteAsync(ActorWriteOperationBatch operations, CancellationToken cancellationToken = default)
{
Log.WriteOperationStarted(this._logger, this.ActorId.ToString(), operations.Operations.Count);
// TODO: Turn send & update message operations into storage writes to outbox
var result = await this.Storage.WriteStateAsync(
this.ActorId,
[.. operations.Operations.OfType<ActorStateWriteOperation>()],
operations.ETag,
cancellationToken).ConfigureAwait(false);
Log.WriteOperationCompleted(this._logger, this.ActorId.ToString(), result.Success);
// Check if result success and schedule durable task to pump outbox if needed.
if (result.Success)
{
var processedOperations = 0;
foreach (var operation in operations.Operations)
{
if (operation is SendRequestOperation sendRequestOperation)
{
Log.SendRequestOperationEncountered(this._logger, this.ActorId.ToString());
// Get the target actor from the runtime.
// Enqueue the request on the actor's inbox.
throw new NotImplementedException();
}
else if (operation is UpdateRequestOperation updateRequestOperation)
{
Log.UpdateRequestOperationProcessing(this._logger, this.ActorId.ToString(), updateRequestOperation.MessageId);
// Find the request in this actor's inbox.
// Get the SenderId from the request.
// Get the sending actor from the runtime.
// Enqueue the request on the actor's inbox.
this.PostRequestUpdate(updateRequestOperation);
processedOperations++;
}
}
Log.OperationProcessingCompleted(this._logger, this.ActorId.ToString(), processedOperations);
}
return result;
}
public async ValueTask DisposeAsync()
{
Log.ActorContextDisposing(this._logger, this.ActorId.ToString());
this._cts.Dispose();
await this._actorInstance.DisposeAsync().ConfigureAwait(false);
if (this._actorRunTask is { } actorRunTask)
{
await actorRunTask.ConfigureAwait(false);
}
Log.ActorContextDisposed(this._logger, this.ActorId.ToString());
}
public void Dispose()
{
Log.ActorContextDisposing(this._logger, this.ActorId.ToString());
this._cts.Dispose();
#pragma warning disable CA2012 // Use ValueTasks correctly
#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits
if (this._actorInstance is IDisposable actorInstanceDisposable)
{
actorInstanceDisposable.Dispose();
}
else
{
this._actorInstance.DisposeAsync().GetAwaiter().GetResult();
}
this._actorRunTask?.GetAwaiter().GetResult();
#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits
#pragma warning restore CA2012 // Use ValueTasks correctly
Log.ActorContextDisposed(this._logger, this.ActorId.ToString());
}
private sealed class ActorInboxEntry(ActorRequestMessage Request)
{
private readonly TaskCompletionSource<ActorResponseMessage> _responseTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly Channel<UpdateRequestOperation> _updates = Channel.CreateUnbounded<UpdateRequestOperation>();
public CancellationTokenSource Cts { get; } = new();
public ActorRequestMessage Request { get; } = Request;
public Task<ActorResponseMessage> Response => this._responseTcs.Task;
public IAsyncEnumerable<UpdateRequestOperation> WatchUpdatesAsync(CancellationToken cancellationToken)
=> this._updates.Reader.ReadAllAsync(cancellationToken);
public void PostUpdate(UpdateRequestOperation update)
{
if (!this._updates.Writer.TryWrite(update))
{
throw new InvalidOperationException("Failed to write update to the channel.");
}
}
public void SetResponse(ActorResponseMessage response)
{
if (!this._responseTcs.TrySetResult(response))
{
throw new InvalidOperationException("Response has already been set.");
}
this._updates.Writer.TryComplete();
}
}
private sealed class InProcessActorResponseHandle(InProcessActorContext context, ActorInboxEntry entry) : ActorResponseHandle
{
#if NET8_0_OR_GREATER
public override async ValueTask CancelAsync(CancellationToken cancellationToken)
{
await entry.Cts.CancelAsync().ConfigureAwait(false);
}
#else
public override ValueTask CancelAsync(CancellationToken cancellationToken)
{
entry.Cts.Cancel();
return default;
}
#endif
public override async ValueTask<ActorResponse> GetResponseAsync(CancellationToken cancellationToken)
{
ActorResponse response;
#pragma warning disable CA1031 // Do not catch general exception types
try
{
var responseMessage = await entry.Response
#if NET8_0_OR_GREATER
.WaitAsync(cancellationToken)
#endif
.ConfigureAwait(false);
response = new ActorResponse
{
ActorId = context.ActorId,
MessageId = entry.Request.MessageId,
Data = responseMessage.Data,
Status = responseMessage.Status,
};
}
catch (Exception exception)
{
response = new ActorResponse
{
ActorId = context.ActorId,
MessageId = entry.Request.MessageId,
Data = JsonSerializer.SerializeToElement($"Error: {exception.Message}", context._runtime.JsonSerializerOptions.GetTypeInfo<string>(ActorRuntimeJsonContext.Default)),
Status = RequestStatus.Failed,
};
}
#pragma warning restore CA1031 // Do not catch general exception types
return response;
}
public override bool TryGetResponse([NotNullWhen(true)] out ActorResponse? response)
{
if (entry.Response.Status is TaskStatus.RanToCompletion)
{
#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits
var responseMessage = entry.Response.GetAwaiter().GetResult();
#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits
response = new ActorResponse
{
ActorId = context.ActorId,
MessageId = entry.Request.MessageId,
Data = responseMessage.Data,
Status = responseMessage.Status,
};
return true;
}
response = null;
return false;
}
public override async IAsyncEnumerable<ActorRequestUpdate> WatchUpdatesAsync([EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (var update in entry.WatchUpdatesAsync(cancellationToken).ConfigureAwait(false))
{
yield return new ActorRequestUpdate(update.Status, update.Data);
}
}
}
}
@@ -0,0 +1,211 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using static Microsoft.Extensions.AI.Agents.Runtime.ActivityExtensions;
using Tel = Microsoft.Extensions.AI.Agents.Runtime.ActorRuntimeOpenTelemetryConsts;
namespace Microsoft.Extensions.AI.Agents.Runtime;
internal sealed class InProcessActorRuntime(
IServiceProvider serviceProvider,
IReadOnlyDictionary<ActorType, Func<IServiceProvider, IActorRuntimeContext, IActor>> actorFactories,
IActorStateStorage storage,
JsonSerializerOptions jsonSerializerOptions)
{
private static readonly ActivitySource ActivitySource = new(ActorRuntimeOpenTelemetryConsts.InProcessSourceName);
private static readonly Meter Meter = new(ActorRuntimeOpenTelemetryConsts.InProcessSourceName);
// Metrics following OpenTelemetry semantic conventions
private static readonly Counter<long> ActorCreatedCounter = Meter.CreateCounter<long>(
ActorRuntimeOpenTelemetryConsts.Client.ActorCount.Name,
ActorRuntimeOpenTelemetryConsts.CountUnit,
ActorRuntimeOpenTelemetryConsts.Client.ActorCount.Description);
private static readonly Histogram<double> OperationDurationHistogram = Meter.CreateHistogram<double>(
ActorRuntimeOpenTelemetryConsts.Client.OperationDuration.Name,
"s",
ActorRuntimeOpenTelemetryConsts.Client.OperationDuration.Description);
private readonly object _createActorLock = new();
private readonly IReadOnlyDictionary<ActorType, Func<IServiceProvider, IActorRuntimeContext, IActor>> _actorFactories = actorFactories;
private readonly ConcurrentDictionary<ActorId, InProcessActorContext> _actors = [];
public IActorStateStorage Storage { get; } = storage;
public JsonSerializerOptions JsonSerializerOptions { get; } = jsonSerializerOptions;
public IServiceProvider Services { get; } = serviceProvider;
internal InProcessActorContext GetOrCreateActor(ActorId actorId)
{
var stopwatch = Stopwatch.StartNew();
// Create span following OpenTelemetry conventions for RPC operations
using var activity = ActivitySource.StartActivity(
ActorRuntimeOpenTelemetryConsts.SpanNames.FormatActorOperation(ActorRuntimeOpenTelemetryConsts.Operations.GetActor));
try
{
if (this._actors.TryGetValue(actorId, out var context))
{
activity.SetupActorOperation(actorId, exists: true);
activity.Event(ActorStarted, actorId);
return context;
}
if (!this._actorFactories.TryGetValue(actorId.Type, out var factory))
{
var errorMessage = $"No factory registered for actor type '{actorId.Type}'";
var exception = new InvalidOperationException(errorMessage);
activity.SetupActorOperation(actorId, exists: false);
activity.RecordFailure(exception, ActorRuntimeOpenTelemetryConsts.ErrorInfo.Types.ActorNotFound);
throw exception;
}
if (!this._actors.TryGetValue(actorId, out var actorContext))
{
#if NETSTANDARD
InProcessActorContext ValueFactory(ActorId actorId)
{
var self = this;
return CreateActorInstance(actorId, self, factory);
}
actorContext = this._actors.GetOrAdd(actorId, ValueFactory);
#else
static InProcessActorContext ValueFactory(
ActorId actorId,
(InProcessActorRuntime, Func<IServiceProvider, IActorRuntimeContext, IActor>) state)
{
var (self, factory) = state;
return CreateActorInstance(actorId, self, factory);
}
actorContext = this._actors.GetOrAdd(actorId, ValueFactory, (this, factory));
#endif
}
activity.SetupActorOperation(actorId, exists: false);
activity.RecordSuccess();
return actorContext;
}
catch (Exception ex)
{
activity.RecordFailure(ex);
throw;
}
finally
{
// Record operation duration metric
var duration = stopwatch.Elapsed.TotalSeconds;
OperationDurationHistogram.Record(duration,
new KeyValuePair<string, object?>(ActorRuntimeOpenTelemetryConsts.Actor.Operation, ActorRuntimeOpenTelemetryConsts.Operations.GetActor),
new KeyValuePair<string, object?>(ActorRuntimeOpenTelemetryConsts.Actor.Type, actorId.Type.Name));
}
}
private static InProcessActorContext CreateActorInstance(ActorId actorId, InProcessActorRuntime self, Func<IServiceProvider, IActorRuntimeContext, IActor> factory)
{
lock (self._createActorLock)
{
// Create nested span for actor creation
var createActivity = ActivitySource.StartActivity(
ActorRuntimeOpenTelemetryConsts.SpanNames.FormatActorOperation(ActorRuntimeOpenTelemetryConsts.Operations.CreateActor));
InProcessActorContext? instance = null;
try
{
createActivity.SetupActorOperation(actorId);
instance = new InProcessActorContext(actorId, self, factory);
instance.Start();
createActivity.Complete(ActorCreated, actorId, [(Tel.Actor.Started, true)]);
// Record metrics for successful actor creation
ActorCreatedCounter.Add(1, new KeyValuePair<string, object?>(ActorRuntimeOpenTelemetryConsts.Actor.Type, actorId.Type.Name));
return instance;
}
catch (Exception ex)
{
instance?.Dispose();
createActivity.RecordFailure(ex);
throw;
}
}
}
}
internal sealed class InProcessActorClient(InProcessActorRuntime runtime) : IActorClient
{
private static readonly ActivitySource ActivitySource = new(ActorRuntimeOpenTelemetryConsts.InProcessSourceName);
private static readonly Meter ClientMeter = new(ActorRuntimeOpenTelemetryConsts.InProcessSourceName);
private static readonly Counter<long> RequestCounter = ClientMeter.CreateCounter<long>(
ActorRuntimeOpenTelemetryConsts.Client.RequestCount.Name,
ActorRuntimeOpenTelemetryConsts.CountUnit,
ActorRuntimeOpenTelemetryConsts.Client.RequestCount.Description);
private static readonly Histogram<double> ClientOperationDurationHistogram = ClientMeter.CreateHistogram<double>(
ActorRuntimeOpenTelemetryConsts.Client.OperationDuration.Name,
"s",
ActorRuntimeOpenTelemetryConsts.Client.OperationDuration.Description);
private readonly InProcessActorRuntime _runtime = runtime;
public ValueTask<ActorResponseHandle> GetResponseAsync(ActorId actorId, string messageId, CancellationToken cancellationToken)
{
// Create span for get response operation
using var activity = ActivitySource.StartActivity(
ActorRuntimeOpenTelemetryConsts.SpanNames.FormatRequestOperation(ActorRuntimeOpenTelemetryConsts.Operations.ReceiveResponse));
activity.SetupRequestOperation(actorId, messageId, service: "ActorClient", rpcMethod: "GetResponse");
throw new NotImplementedException("GetResponseAsync is not yet implemented");
}
public ValueTask<ActorResponseHandle> SendRequestAsync(ActorRequest request, CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
// Create span for send request operation following RPC client conventions
using var activity = ActivitySource.StartActivity(
ActorRuntimeOpenTelemetryConsts.SpanNames.FormatRequestOperation(ActorRuntimeOpenTelemetryConsts.Operations.SendRequest));
try
{
activity.SetupRequestOperation(request.ActorId, request.MessageId, request.Method);
// Ensure the message is enqueued on the actor's inbox, getting a response handle for it.
var actorId = request.ActorId;
var actorContext = this._runtime.GetOrCreateActor(actorId);
var response = actorContext.SendRequest(request);
activity.Complete(MessageSent, actorId, Sent, (Tel.Message.Id, request.MessageId));
// Record request metric
RequestCounter.Add(1,
new KeyValuePair<string, object?>(Tel.Actor.Type, actorId.Type.Name),
new KeyValuePair<string, object?>(Tel.Message.Method, request.Method));
return new(response);
}
catch (Exception ex)
{
activity.RecordFailure(ex, null, (ActorRuntimeOpenTelemetryConsts.Request.Status, "failed"));
throw;
}
finally
{
// Record operation duration
var duration = stopwatch.Elapsed.TotalSeconds;
ClientOperationDurationHistogram.Record(duration,
new KeyValuePair<string, object?>(ActorRuntimeOpenTelemetryConsts.Actor.Operation, ActorRuntimeOpenTelemetryConsts.Operations.SendRequest),
new KeyValuePair<string, object?>(ActorRuntimeOpenTelemetryConsts.Actor.Type, request.ActorId.Type.Name));
}
}
}
@@ -0,0 +1,33 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
namespace Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// Provides extension methods for JSON serialization with source generation support.
/// </summary>
internal static class JsonSerializerExtensions
{
/// <summary>
/// Gets the JsonTypeInfo for a type, preferring the one from options if available,
/// otherwise falling back to the source-generated context.
/// </summary>
/// <typeparam name="T">The type to get JsonTypeInfo for.</typeparam>
/// <param name="options">The JsonSerializerOptions to check first.</param>
/// <param name="fallbackContext">The fallback JsonSerializerContext to use if not found in options.</param>
/// <returns>The JsonTypeInfo for the requested type.</returns>
public static JsonTypeInfo<T> GetTypeInfo<T>(this JsonSerializerOptions options, JsonSerializerContext fallbackContext)
{
// Try to get from the options first (if a context is configured)
if (options.TypeInfoResolver?.GetTypeInfo(typeof(T), options) is JsonTypeInfo<T> typeInfo)
{
return typeInfo;
}
// Fall back to the provided source-generated context
return (JsonTypeInfo<T>)fallbackContext.GetTypeInfo(typeof(T))!;
}
}
@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
<NoWarn>$(NoWarn);IDE1006;IDE0130</NoWarn>
<VersionSuffix>alpha</VersionSuffix>
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectDiagnosticAttributesOnLegacy>true</InjectDiagnosticAttributesOnLegacy>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Extensions.AI.Agents.Runtime.Abstractions.UnitTests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' != '.NETCoreApp'">
<PackageReference Include="Microsoft.Bcl.HashCode" />
<PackageReference Include="System.Text.Json" />
<PackageReference Include="System.Threading.Channels" />
<PackageReference Include="System.Threading.Tasks.Extensions" />
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Extensions.AI.Agents.Runtime.Abstractions\Microsoft.Extensions.AI.Agents.Runtime.Abstractions.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents;
/// <summary>
/// Source-generated JSON type information for use by all Agents implementations.
/// </summary>
[JsonSourceGenerationOptions(
JsonSerializerDefaults.Web,
UseStringEnumConverter = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false)]
[JsonSerializable(typeof(ChatMessage))]
[JsonSerializable(typeof(List<ChatMessage>))]
[JsonSerializable(typeof(ChatClientAgentThread))]
internal sealed partial class AgentsJsonContext : JsonSerializerContext;
@@ -1,8 +1,12 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
@@ -12,6 +16,7 @@ namespace Microsoft.Extensions.AI.Agents;
/// <summary>
/// Chat client agent thread.
/// </summary>
[JsonConverter(typeof(Converter))]
public sealed class ChatClientAgentThread : AgentThread, IMessagesRetrievableThread
{
private readonly List<ChatMessage> _chatMessages = [];
@@ -89,4 +94,92 @@ public sealed class ChatClientAgentThread : AgentThread, IMessagesRetrievableThr
return Task.CompletedTask;
}
/// <summary>
/// Provides a <see cref="JsonConverter"/> for <see cref="ChatClientAgentThread"/> objects.
/// </summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public sealed class Converter : JsonConverter<ChatClientAgentThread>
{
/// <inheritdoc/>
public override ChatClientAgentThread? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.StartObject)
{
throw new JsonException("Expected StartObject token");
}
using var doc = JsonDocument.ParseValue(ref reader);
var root = doc.RootElement;
// Extract properties from JSON
string? id = null;
if (root.TryGetProperty("id", out var idProperty))
{
id = idProperty.GetString();
}
List<ChatMessage>? messages = null;
if (root.TryGetProperty("messages", out var messagesProperty))
{
if (messagesProperty.ValueKind == JsonValueKind.Array)
{
messages = [];
foreach (var messageElement in messagesProperty.EnumerateArray())
{
var message = messageElement.Deserialize(options.GetTypeInfo<ChatMessage>(AgentsJsonContext.Default));
if (message != null)
{
messages.Add(message);
}
}
}
}
// Create the appropriate instance based on available data
// StorageLocation will be set automatically by the constructors
ChatClientAgentThread thread;
if (messages?.Count > 0)
{
thread = new ChatClientAgentThread(messages);
}
else if (!string.IsNullOrWhiteSpace(id))
{
thread = new ChatClientAgentThread(id);
}
else
{
thread = new ChatClientAgentThread();
}
// Override Id if it was explicitly set in JSON (for cases where messages exist but ID is also provided)
if (id != null)
{
thread.Id = id;
}
return thread;
}
/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, ChatClientAgentThread value, JsonSerializerOptions options)
{
writer.WriteStartObject();
// Write base properties
if (value.Id != null)
{
writer.WriteString("id", value.Id);
}
// Write messages if in memory storage (StorageLocation is determined by presence of messages vs ID)
if (value.StorageLocation == ChatClientAgentThreadType.InMemoryMessages)
{
writer.WritePropertyName("messages");
JsonSerializer.Serialize(writer, value._chatMessages, options.GetTypeInfo<List<ChatMessage>>(AgentsJsonContext.Default));
}
writer.WriteEndObject();
}
}
}
@@ -0,0 +1,33 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
namespace Microsoft.Extensions.AI.Agents;
/// <summary>
/// Provides extension methods for JSON serialization with source generation support.
/// </summary>
internal static class JsonSerializerExtensions
{
/// <summary>
/// Gets the JsonTypeInfo for a type, preferring the one from options if available,
/// otherwise falling back to the source-generated context.
/// </summary>
/// <typeparam name="T">The type to get JsonTypeInfo for.</typeparam>
/// <param name="options">The JsonSerializerOptions to check first.</param>
/// <param name="fallbackContext">The fallback JsonSerializerContext to use if not found in options.</param>
/// <returns>The JsonTypeInfo for the requested type.</returns>
public static JsonTypeInfo<T> GetTypeInfo<T>(this JsonSerializerOptions options, JsonSerializerContext fallbackContext)
{
// Try to get from the options first (if a context is configured)
if (options.TypeInfoResolver?.GetTypeInfo(typeof(T), options) is JsonTypeInfo<T> typeInfo)
{
return typeInfo;
}
// Fall back to the provided source-generated context
return (JsonTypeInfo<T>)fallbackContext.GetTypeInfo(typeof(T))!;
}
}
@@ -9,6 +9,7 @@
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectDiagnosticClassesOnLegacy>true</InjectDiagnosticClassesOnLegacy>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />