Round 3 of cleanup (#186)

- Enable warnings as errors
- Make the remaining src projects NativeAOT compatible
- Use Throw helpers
This commit is contained in:
Stephen Toub
2025-07-20 23:07:50 -04:00
committed by GitHub
Unverified
parent ccd7a44ec7
commit 6c12b2c0f8
36 changed files with 223 additions and 182 deletions
+2 -1
View File
@@ -9,7 +9,8 @@
<LangVersion>12</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<NoWarn>$(NoWarn);IDE0290;IDE0079</NoWarn>
<NoWarn>$(NoWarn);IDE0290;IDE0079;NU5128</NoWarn>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ProjectsTargetFrameworks>net9.0;net8.0;netstandard2.0;net472</ProjectsTargetFrameworks>
<ProjectsDebugTargetFrameworks>net9.0;net472</ProjectsDebugTargetFrameworks>
<IsAotCompatible Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net7.0'))">true</IsAotCompatible>
@@ -609,7 +609,7 @@ internal static class OpenAIClientExtensions2
// Roundtrip the schema through the ToolJson model type to remove extra properties
// and force missing ones into existence, then return the serialized UTF8 bytes as BinaryData.
var tool = JsonSerializer.Deserialize(jsonSchema, OpenAIJsonContext.Default.ToolJson)!;
var tool = jsonSchema.Deserialize(OpenAIJsonContext.Default.ToolJson)!;
var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool, OpenAIJsonContext.Default.ToolJson));
return functionParameters;
@@ -167,19 +167,19 @@ public class GroupChatOrchestration_With_AIManager(ITestOutputHelper output) : O
}
/// <inheritdoc/>
public override ValueTask<GroupChatManagerResult<string>> FilterResults(IReadOnlyCollection<ChatMessage> history, CancellationToken cancellationToken = default) =>
protected override ValueTask<GroupChatManagerResult<string>> FilterResults(IReadOnlyCollection<ChatMessage> history, CancellationToken cancellationToken = default) =>
this.GetResponseAsync<string>(history, Prompts.Filter(topic), cancellationToken);
/// <inheritdoc/>
public override ValueTask<GroupChatManagerResult<string>> SelectNextAgent(IReadOnlyCollection<ChatMessage> history, GroupChatTeam team, CancellationToken cancellationToken = default) =>
protected override ValueTask<GroupChatManagerResult<string>> SelectNextAgent(IReadOnlyCollection<ChatMessage> history, GroupChatTeam team, CancellationToken cancellationToken = default) =>
this.GetResponseAsync<string>(history, Prompts.Selection(topic, team.FormatList()), cancellationToken);
/// <inheritdoc/>
public override ValueTask<GroupChatManagerResult<bool>> ShouldRequestUserInput(IReadOnlyCollection<ChatMessage> history, CancellationToken cancellationToken = default) =>
protected override ValueTask<GroupChatManagerResult<bool>> ShouldRequestUserInput(IReadOnlyCollection<ChatMessage> history, CancellationToken cancellationToken = default) =>
new(new GroupChatManagerResult<bool>(false) { Reason = "The AI group chat manager does not request user input." });
/// <inheritdoc/>
public override async ValueTask<GroupChatManagerResult<bool>> ShouldTerminate(IReadOnlyCollection<ChatMessage> history, CancellationToken cancellationToken = default)
protected override async ValueTask<GroupChatManagerResult<bool>> ShouldTerminate(IReadOnlyCollection<ChatMessage> history, CancellationToken cancellationToken = default)
{
GroupChatManagerResult<bool> result = await base.ShouldTerminate(history, cancellationToken);
if (!result.Value)
@@ -84,7 +84,7 @@ public class GroupChatOrchestration_With_HumanInTheLoop(ITestOutputHelper output
/// </remarks>
private sealed class CustomRoundRobinGroupChatManager : RoundRobinGroupChatManager
{
public override ValueTask<GroupChatManagerResult<bool>> ShouldRequestUserInput(IReadOnlyCollection<ChatMessage> history, CancellationToken cancellationToken = default)
protected override ValueTask<GroupChatManagerResult<bool>> ShouldRequestUserInput(IReadOnlyCollection<ChatMessage> history, CancellationToken cancellationToken = default)
{
string? lastAgent = history.LastOrDefault()?.AuthorName;
@@ -4,6 +4,4 @@
namespace System.Runtime.CompilerServices;
internal static class IsExternalInit
{
}
internal static class IsExternalInit;
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -17,7 +18,7 @@ public abstract partial class AgentOrchestration<TInput, TOutput>
/// </summary>
private sealed class RequestActor : OrchestrationActor
{
private readonly Func<TInput, CancellationToken, ValueTask<IEnumerable<ChatMessage>>> _transform;
private readonly Func<TInput, JsonSerializerOptions?, CancellationToken, ValueTask<IEnumerable<ChatMessage>>> _transform;
private readonly Func<IEnumerable<ChatMessage>, ValueTask> _action;
private readonly TaskCompletionSource<TOutput> _completionSource;
@@ -35,7 +36,7 @@ public abstract partial class AgentOrchestration<TInput, TOutput>
ActorId id,
IAgentRuntime runtime,
OrchestrationContext context,
Func<TInput, CancellationToken, ValueTask<IEnumerable<ChatMessage>>> transform,
Func<TInput, JsonSerializerOptions?, CancellationToken, ValueTask<IEnumerable<ChatMessage>>> transform,
TaskCompletionSource<TOutput> completionSource,
Func<IEnumerable<ChatMessage>, ValueTask> action,
ILogger<RequestActor>? logger = null)
@@ -60,8 +61,8 @@ public abstract partial class AgentOrchestration<TInput, TOutput>
this.Logger.LogOrchestrationRequestInvoke(this.Context.Orchestration, this.Id);
try
{
IEnumerable<ChatMessage> input = await this._transform.Invoke(item, cancellationToken).ConfigureAwait(false);
Task task = this._action.Invoke(input).AsTask();
IEnumerable<ChatMessage> input = await this._transform.Invoke(item, messageContext.SerializerOptions, cancellationToken).ConfigureAwait(false);
var task = this._action.Invoke(input);
this.Logger.LogOrchestrationStart(this.Context.Orchestration, this.Id);
await task.ConfigureAwait(false);
}
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -19,7 +20,7 @@ public abstract partial class AgentOrchestration<TInput, TOutput>
{
private readonly TaskCompletionSource<TOutput> _completionSource;
private readonly Func<TResult, IList<ChatMessage>> _transformResult;
private readonly Func<IList<ChatMessage>, CancellationToken, ValueTask<TOutput>> _transform;
private readonly Func<IList<ChatMessage>, JsonSerializerOptions?, CancellationToken, ValueTask<TOutput>> _transform;
/// <summary>
/// Initializes a new instance of the <see cref="AgentOrchestration{TInput, TOutput}.ResultActor{TResult}"/> class.
@@ -36,7 +37,7 @@ public abstract partial class AgentOrchestration<TInput, TOutput>
IAgentRuntime runtime,
OrchestrationContext context,
Func<TResult, IList<ChatMessage>> transformResult,
Func<IList<ChatMessage>, CancellationToken, ValueTask<TOutput>> transformOutput,
Func<IList<ChatMessage>, JsonSerializerOptions?, CancellationToken, ValueTask<TOutput>> transformOutput,
TaskCompletionSource<TOutput> completionSource,
ILogger<ResultActor<TResult>>? logger = null)
: base(id, runtime, context, $"{id.Type}_Actor", logger)
@@ -66,7 +67,7 @@ public abstract partial class AgentOrchestration<TInput, TOutput>
if (!this._completionSource.Task.IsCompleted)
{
IList<ChatMessage> result = this._transformResult.Invoke(item);
TOutput output = await this._transform.Invoke(result, cancellationToken).ConfigureAwait(false);
TOutput output = await this._transform.Invoke(result, messageContext.SerializerOptions, cancellationToken).ConfigureAwait(false);
this._completionSource.TrySetResult(output);
}
}
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -16,11 +17,6 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// Called when human interaction is requested.
/// </summary>
public delegate ValueTask<ChatMessage> OrchestrationInteractiveCallback();
/// <summary>
/// Base class for multi-agent agent orchestration patterns.
/// </summary>
@@ -67,22 +63,22 @@ public abstract partial class AgentOrchestration<TInput, TOutput>
/// <summary>
/// Transforms the orchestration input into a source input suitable for processing.
/// </summary>
public Func<TInput, CancellationToken, ValueTask<IEnumerable<ChatMessage>>> InputTransform { get; init; } = DefaultTransforms.FromInput<TInput>;
public Func<TInput, JsonSerializerOptions?, CancellationToken, ValueTask<IEnumerable<ChatMessage>>>? InputTransform { get; set; }
/// <summary>
/// Transforms the processed result into the final output form.
/// </summary>
public Func<IList<ChatMessage>, CancellationToken, ValueTask<TOutput>> ResultTransform { get; init; } = DefaultTransforms.ToOutput<TOutput>;
public Func<IList<ChatMessage>, JsonSerializerOptions?, CancellationToken, ValueTask<TOutput>>? ResultTransform { get; set; }
/// <summary>
/// Optional callback that is invoked for every agent response.
/// </summary>
public Func<IEnumerable<ChatMessage>, ValueTask>? ResponseCallback { get; init; }
public Func<IEnumerable<ChatMessage>, ValueTask>? ResponseCallback { get; set; }
/// <summary>
/// Optional callback that is invoked for every agent update.
/// </summary>
public Func<AgentRunResponseUpdate, ValueTask>? StreamingResponseCallback { get; init; }
public Func<AgentRunResponseUpdate, ValueTask>? StreamingResponseCallback { get; set; }
/// <summary>
/// Gets the list of member targets involved in the orchestration.
@@ -183,7 +179,7 @@ public abstract partial class AgentOrchestration<TInput, TOutput>
logger.LogOrchestrationRegistrationStart(context.Orchestration, context.Topic);
// Register orchestration
RegistrationContext registrar = new(this.FormatAgentType(context.Topic, "Root"), runtime, context, completion, this.ResultTransform);
RegistrationContext registrar = new(this.FormatAgentType(context.Topic, "Root"), runtime, context, completion, this.ResultTransform ?? DefaultTransforms.ToOutput<TOutput>);
ActorType? entryAgent = await this.RegisterOrchestrationAsync(runtime, context, registrar, logger).ConfigureAwait(false);
// Register actor for orchestration entry-point
@@ -196,7 +192,7 @@ public abstract partial class AgentOrchestration<TInput, TOutput>
new(agentId,
runtime,
context,
this.InputTransform,
this.InputTransform ?? DefaultTransforms.FromInput<TInput>,
completion,
input => this.StartAsync(runtime, context.Topic, input, entryAgent),
context.LoggerFactory.CreateLogger<RequestActor>());
@@ -216,7 +212,7 @@ public abstract partial class AgentOrchestration<TInput, TOutput>
IAgentRuntime runtime,
OrchestrationContext context,
TaskCompletionSource<TOutput> completion,
Func<IList<ChatMessage>, CancellationToken, ValueTask<TOutput>> outputTransform)
Func<IList<ChatMessage>, JsonSerializerOptions?, CancellationToken, ValueTask<TOutput>> outputTransform)
{
/// <summary>
/// Register the final result type.
@@ -20,7 +20,7 @@ public sealed class ConcurrentOrchestration : ConcurrentOrchestration<string, st
: base(members)
{
this.ResultTransform =
(response, cancellationToken) =>
(response, _, cancellationToken) =>
{
string[] result = [.. response.Select(r => r.Text)];
return new ValueTask<string[]>(result);
@@ -6,68 +6,58 @@ using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.Orchestration;
internal static class DefaultTransforms
{
public static ValueTask<IEnumerable<ChatMessage>> FromInput<TInput>(TInput input, CancellationToken cancellationToken = default) =>
new(input switch
public static ValueTask<IEnumerable<ChatMessage>> FromInput<TInput>(TInput input, JsonSerializerOptions? serializerOptions = null, CancellationToken cancellationToken = default)
{
serializerOptions ??= AgentAbstractionsJsonUtilities.DefaultOptions;
return new(input switch
{
IEnumerable<ChatMessage> messages => messages,
ChatMessage message => [message],
string text => [new ChatMessage(ChatRole.User, text)],
_ => [new ChatMessage(ChatRole.User, JsonSerializer.Serialize(input))]
_ => [new ChatMessage(ChatRole.User, JsonSerializer.Serialize(input, serializerOptions.GetTypeInfo(typeof(TInput))))]
});
}
public static ValueTask<TOutput> ToOutput<TOutput>(IList<ChatMessage> result, CancellationToken cancellationToken = default)
public static ValueTask<TOutput> ToOutput<TOutput>(IList<ChatMessage> result, JsonSerializerOptions? serializerOptions = null, CancellationToken cancellationToken = default)
{
Throw.IfNull(result);
serializerOptions ??= AgentAbstractionsJsonUtilities.DefaultOptions;
bool isSingleResult = result.Count == 1;
TOutput output =
GetDefaultOutput() ??
GetObjectOutput() ??
throw new InvalidOperationException($"Unable to transform output to {typeof(TOutput)}.");
return new ValueTask<TOutput>(output);
TOutput? GetObjectOutput()
if (result is TOutput)
{
if (isSingleResult)
{
try
{
return JsonSerializer.Deserialize<TOutput>(result[0].Text);
}
catch (JsonException)
{
}
}
return default;
return new((TOutput)(object)result);
}
TOutput? GetDefaultOutput()
if (isSingleResult)
{
if (typeof(TOutput).IsInstanceOfType(result))
if (typeof(ChatMessage).IsAssignableFrom(typeof(TOutput)))
{
return (TOutput)(object)result;
return new((TOutput)(object)result[0]);
}
if (isSingleResult)
if (typeof(string) == typeof(TOutput))
{
if (typeof(ChatMessage).IsAssignableFrom(typeof(TOutput)))
{
return (TOutput)(object)result[0];
}
if (typeof(string) == typeof(TOutput))
{
return (TOutput)(object)(result[0].Text ?? string.Empty);
}
return new((TOutput)(object)(result[0].Text ?? string.Empty));
}
return default;
try
{
return new((TOutput)JsonSerializer.Deserialize(result[0].Text, serializerOptions.GetTypeInfo(typeof(TOutput)))!);
}
catch (JsonException)
{
}
}
throw new InvalidOperationException($"Unable to transform output to {typeof(TOutput)}.");
}
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
@@ -50,7 +51,7 @@ public abstract class GroupChatManager
/// <summary>
/// Gets or sets the callback to be invoked for interactive input.
/// </summary>
public OrchestrationInteractiveCallback? InteractiveCallback { get; init; }
public Func<ValueTask<ChatMessage>>? InteractiveCallback { get; init; }
/// <summary>
/// Filters the results of the group chat based on the provided chat history.
@@ -58,7 +59,7 @@ public abstract class GroupChatManager
/// <param name="history">The chat history to filter.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A <see cref="GroupChatManagerResult{TValue}"/> containing the filtered result as a string.</returns>
public abstract ValueTask<GroupChatManagerResult<string>> FilterResults(IReadOnlyCollection<ChatMessage> history, CancellationToken cancellationToken = default);
protected internal abstract ValueTask<GroupChatManagerResult<string>> FilterResults(IReadOnlyCollection<ChatMessage> history, CancellationToken cancellationToken = default);
/// <summary>
/// Selects the next agent to participate in the group chat based on the provided chat history and team.
@@ -67,7 +68,7 @@ public abstract class GroupChatManager
/// <param name="team">The group of agents participating in the chat.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A <see cref="GroupChatManagerResult{TValue}"/> containing the identifier of the next agent as a string.</returns>
public abstract ValueTask<GroupChatManagerResult<string>> SelectNextAgent(IReadOnlyCollection<ChatMessage> history, GroupChatTeam team, CancellationToken cancellationToken = default);
protected internal abstract ValueTask<GroupChatManagerResult<string>> SelectNextAgent(IReadOnlyCollection<ChatMessage> history, GroupChatTeam team, CancellationToken cancellationToken = default);
/// <summary>
/// Determines whether user input should be requested based on the provided chat history.
@@ -75,7 +76,7 @@ public abstract class GroupChatManager
/// <param name="history">The chat history to consider.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A <see cref="GroupChatManagerResult{TValue}"/> indicating whether user input should be requested.</returns>
public abstract ValueTask<GroupChatManagerResult<bool>> ShouldRequestUserInput(IReadOnlyCollection<ChatMessage> history, CancellationToken cancellationToken = default);
protected internal abstract ValueTask<GroupChatManagerResult<bool>> ShouldRequestUserInput(IReadOnlyCollection<ChatMessage> history, CancellationToken cancellationToken = default);
/// <summary>
/// Determines whether the group chat should be terminated based on the provided chat history and invocation count.
@@ -83,7 +84,7 @@ public abstract class GroupChatManager
/// <param name="history">The chat history to consider.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A <see cref="GroupChatManagerResult{TValue}"/> indicating whether the chat should be terminated.</returns>
public virtual ValueTask<GroupChatManagerResult<bool>> ShouldTerminate(IReadOnlyCollection<ChatMessage> history, CancellationToken cancellationToken = default)
protected internal virtual ValueTask<GroupChatManagerResult<bool>> ShouldTerminate(IReadOnlyCollection<ChatMessage> history, CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref this._invocationCount);
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -39,8 +38,9 @@ public class GroupChatOrchestration<TInput, TOutput> :
{
if (!entryAgent.HasValue)
{
throw new ArgumentException("Entry agent is not defined.", nameof(entryAgent));
Throw.ArgumentException(nameof(entryAgent), "Entry agent is not defined.");
}
return runtime.PublishMessageAsync(new GroupChatMessages.InputTask(input), entryAgent.Value);
}
@@ -19,14 +19,14 @@ public class RoundRobinGroupChatManager : GroupChatManager
private int _currentAgentIndex;
/// <inheritdoc/>
public override ValueTask<GroupChatManagerResult<string>> FilterResults(IReadOnlyCollection<ChatMessage> history, CancellationToken cancellationToken = default)
protected internal override ValueTask<GroupChatManagerResult<string>> FilterResults(IReadOnlyCollection<ChatMessage> history, CancellationToken cancellationToken = default)
{
GroupChatManagerResult<string> result = new(history.LastOrDefault()?.Text ?? string.Empty) { Reason = "Default result filter provides the final chat message." };
return new ValueTask<GroupChatManagerResult<string>>(result);
}
/// <inheritdoc/>
public override ValueTask<GroupChatManagerResult<string>> SelectNextAgent(IReadOnlyCollection<ChatMessage> history, GroupChatTeam team, CancellationToken cancellationToken = default)
protected internal override ValueTask<GroupChatManagerResult<string>> SelectNextAgent(IReadOnlyCollection<ChatMessage> history, GroupChatTeam team, CancellationToken cancellationToken = default)
{
string nextAgent = team.Skip(this._currentAgentIndex).First().Key;
this._currentAgentIndex = (this._currentAgentIndex + 1) % team.Count;
@@ -35,7 +35,7 @@ public class RoundRobinGroupChatManager : GroupChatManager
}
/// <inheritdoc/>
public override ValueTask<GroupChatManagerResult<bool>> ShouldRequestUserInput(IReadOnlyCollection<ChatMessage> history, CancellationToken cancellationToken = default)
protected internal override ValueTask<GroupChatManagerResult<bool>> ShouldRequestUserInput(IReadOnlyCollection<ChatMessage> history, CancellationToken cancellationToken = default)
{
GroupChatManagerResult<bool> result = new(false) { Reason = "The default round-robin group chat manager does not request user input." };
return new ValueTask<GroupChatManagerResult<bool>>(result);
@@ -9,6 +9,7 @@ using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Extensions.AI.Agents.Runtime;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.Orchestration;
@@ -39,21 +40,19 @@ internal sealed partial class HandoffActor : AgentActor
public HandoffActor(ActorId id, IAgentRuntime runtime, OrchestrationContext context, ChatClientAgent agent, HandoffLookup handoffs, ActorType resultHandoff, ILogger<HandoffActor>? logger = null)
: base(id, runtime, context, agent, logger)
{
Throw.IfNull(handoffs);
Throw.IfNull(resultHandoff);
if (handoffs.ContainsKey(agent.Name ?? agent.Id))
{
throw new ArgumentException($"The agent {agent.Name ?? agent.Id} cannot have a handoff to itself.", nameof(handoffs));
Throw.ArgumentException(nameof(handoffs), $"The agent {agent.Name ?? agent.Id} cannot have a handoff to itself.");
}
this._cache = [];
this._chatAgent = agent;
this._handoffs = handoffs;
this._resultHandoff = resultHandoff;
this._options =
new ChatOptions
{
Tools = [.. this.CreateHandoffFunctions()],
ToolMode = ChatToolMode.Auto
};
this._options = new() { Tools = this.CreateHandoffFunctions() };
this.RegisterMessageHandler<HandoffMessages.InputTask>(this.Handle);
this.RegisterMessageHandler<HandoffMessages.Request>(this.HandleAsync);
@@ -73,7 +72,7 @@ internal sealed partial class HandoffActor : AgentActor
/// <summary>
/// Gets or sets the callback to be invoked for interactive input.
/// </summary>
public OrchestrationInteractiveCallback? InteractiveCallback { get; init; }
public Func<ValueTask<ChatMessage>>? InteractiveCallback { get; init; }
private void Handle(HandoffMessages.InputTask item, MessageContext messageContext)
{
@@ -144,23 +143,24 @@ internal sealed partial class HandoffActor : AgentActor
}
}
private IEnumerable<AIFunction> CreateHandoffFunctions()
private List<AITool> CreateHandoffFunctions()
{
yield return AIFunctionFactory.Create(
List<AITool> functions = [];
functions.Add(AIFunctionFactory.Create(
this.EndAsync,
name: "end_task",
description: "Complete the task with a summary when no further requests are given.");
description: "Complete the task with a summary when no further requests are given."));
foreach (KeyValuePair<string, (ActorType AgentType, string Description)> handoff in this._handoffs)
{
AIFunction handoffFunction =
AIFunctionFactory.Create(
functions.Add(AIFunctionFactory.Create(
() => this.Handoff(handoff.Key),
name: $"transfer_to_{InvalidNameCharsRegex().Replace(handoff.Key, "_")}",
description: handoff.Value.Description);
yield return handoffFunction;
description: handoff.Value.Description));
}
return functions;
}
private void Handoff(string agentName)
@@ -8,6 +8,7 @@ using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Extensions.AI.Agents.Runtime;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.Orchestration;
@@ -41,7 +42,7 @@ public class HandoffOrchestration<TInput, TOutput> : AgentOrchestration<TInput,
// Fail fast if invalid names are present.
if (badNames.Length > 0)
{
throw new ArgumentException($"The following agents are not defined in the orchestration: {string.Join(", ", badNames)}", nameof(handoffs));
Throw.ArgumentException(nameof(handoffs), $"The following agents are not defined in the orchestration: {string.Join(", ", badNames)}");
}
this._handoffs = handoffs;
@@ -50,15 +51,13 @@ public class HandoffOrchestration<TInput, TOutput> : AgentOrchestration<TInput,
/// <summary>
/// Gets or sets the callback to be invoked for interactive input.
/// </summary>
public OrchestrationInteractiveCallback? InteractiveCallback { get; init; }
public Func<ValueTask<ChatMessage>>? InteractiveCallback { get; init; }
/// <inheritdoc />
protected override async ValueTask StartAsync(IAgentRuntime runtime, TopicId topic, IEnumerable<ChatMessage> input, ActorType? entryAgent)
{
if (!entryAgent.HasValue)
{
throw new ArgumentException("Entry agent is not defined.", nameof(entryAgent));
}
Throw.IfNull(entryAgent);
await runtime.PublishMessageAsync(new HandoffMessages.InputTask([.. input]), topic).ConfigureAwait(false);
await runtime.PublishMessageAsync(new HandoffMessages.Request(), entryAgent.Value).ConfigureAwait(false);
}
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Extensions.AI.Agents.Runtime;
@@ -82,7 +81,7 @@ public sealed class OrchestrationHandoffs : Dictionary<string, AgentHandoffs>
{
if (string.IsNullOrWhiteSpace(target.Description) && string.IsNullOrWhiteSpace(target.Name))
{
throw new InvalidOperationException($"The provided target agent with Id '{target.Id}' has no description or name, and no handoff description has been provided. At least one of these are required to register a handoff so that the appropriate target agent can be chosen.");
Throw.InvalidOperationException($"The provided target agent with Id '{target.Id}' has no description or name, and no handoff description has been provided. At least one of these are required to register a handoff so that the appropriate target agent can be chosen.");
}
this.Agents.Add(target);
@@ -5,7 +5,6 @@
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
<RootNamespace>Microsoft.Agents.Orchestration</RootNamespace>
<VersionSuffix>alpha</VersionSuffix>
<IsAotCompatible>false</IsAotCompatible>
</PropertyGroup>
<PropertyGroup>
@@ -2,9 +2,11 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.Orchestration;
@@ -42,17 +44,23 @@ public sealed class StructuredOutputTransform<TOutput>
/// Transforms the provided <see cref="ChatMessage"/> into a strongly-typed structured output by invoking the chat completion service and deserializing the response.
/// </summary>
/// <param name="messages">The chat messages to process.</param>
/// <param name="serializerOptions">The JSON serializer options to use when performing any JSON serialization.</param>
/// <param name="cancellationToken">A cancellation token to observe while waiting for the task to complete.</param>
/// <returns>The structured output of type <typeparamref name="TOutput"/>.</returns>
/// <exception cref="InvalidOperationException">Thrown if the response cannot be deserialized into <typeparamref name="TOutput"/>.</exception>
public async ValueTask<TOutput> TransformAsync(IList<ChatMessage> messages, CancellationToken cancellationToken = default)
public async ValueTask<TOutput> TransformAsync(IList<ChatMessage> messages, JsonSerializerOptions? serializerOptions = null, CancellationToken cancellationToken = default)
{
IEnumerable<ChatMessage> input =
Throw.IfNull(messages);
ChatResponse<TOutput> response = await this._client.GetResponseAsync<TOutput>(
[
new ChatMessage(ChatRole.System, this.Instructions),
.. messages,
];
ChatResponse<TOutput> response = await this._client.GetResponseAsync<TOutput>(input, this._options, useJsonSchemaResponseFormat: true, cancellationToken).ConfigureAwait(false);
],
serializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions,
this._options,
cancellationToken: cancellationToken).ConfigureAwait(false);
return response.Result;
}
}
@@ -207,7 +207,9 @@ public abstract class AIAgent
Func<TThreadType> constructThread)
where TThreadType : AgentThread
{
thread ??= constructThread is not null ? constructThread() : throw new ArgumentNullException(nameof(constructThread));
Throw.IfNull(constructThread);
thread ??= constructThread();
if (thread is not TThreadType concreteThreadType)
{
@@ -0,0 +1,62 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents;
/// <summary>Provides a collection of utility methods for working with JSON data in the context of agents.</summary>
public static partial class AgentAbstractionsJsonUtilities
{
/// <summary>
/// Gets the <see cref="JsonSerializerOptions"/> singleton used as the default in JSON serialization operations.
/// </summary>
/// <remarks>
/// <para>
/// For Native AOT or applications disabling <see cref="JsonSerializer.IsReflectionEnabledByDefault"/>, this instance
/// includes source generated contracts for all common exchange types contained in this library.
/// </para>
/// <para>
/// It additionally turns on the following settings:
/// <list type="number">
/// <item>Enables <see cref="JsonSerializerDefaults.Web"/> defaults.</item>
/// <item>Enables <see cref="JsonIgnoreCondition.WhenWritingNull"/> as the default ignore condition for properties.</item>
/// <item>Enables <see cref="JsonNumberHandling.AllowReadingFromString"/> as the default number handling for number types.</item>
/// </list>
/// </para>
/// </remarks>
public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
/// <summary>
/// Creates default options to use for agents-related serialization.
/// </summary>
/// <returns>The configured options.</returns>
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050:RequiresDynamicCode", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
private static JsonSerializerOptions CreateDefaultOptions()
{
// Copy the configuration from the source generated context.
JsonSerializerOptions options = new(JsonContext.Default.Options);
// Chain with all supported types from MEAI.
options.TypeInfoResolverChain.Add(AIJsonUtilities.DefaultOptions.TypeInfoResolver!);
options.MakeReadOnly();
return options;
}
// Keep in sync with CreateDefaultOptions above.
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
NumberHandling = JsonNumberHandling.AllowReadingFromString)]
// Agent abstraction types
[JsonSerializable(typeof(AgentRunOptions))]
[JsonSerializable(typeof(AgentRunResponse))]
[JsonSerializable(typeof(AgentRunResponseUpdate))]
[JsonSerializable(typeof(AgentThread))]
[ExcludeFromCodeCoverage]
internal sealed partial class JsonContext : JsonSerializerContext;
}
@@ -1,10 +1,14 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
#if NET9_0_OR_GREATER
using System.Buffers;
#endif
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
#if NET9_0_OR_GREATER
using System.Text;
#endif
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
@@ -22,13 +26,6 @@ namespace Microsoft.Extensions.AI.Agents;
/// </remarks>
public class AgentRunResponse
{
private static readonly JsonReaderOptions s_allowMultipleValuesJsonReaderOptions = new()
{
#if NET9_0_OR_GREATER
AllowMultipleValues = true
#endif
};
/// <summary>The response messages.</summary>
private IList<ChatMessage>? _messages;
@@ -184,15 +181,16 @@ public class AgentRunResponse
}
#pragma warning disable CA1031 // Do not catch general exception types
catch
#pragma warning restore CA1031
{
structuredOutput = default;
return false;
}
#pragma warning restore CA1031 // Do not catch general exception types
}
private static T? DeserializeFirstTopLevelObject<T>(string json, JsonTypeInfo<T> typeInfo)
{
#if NET9_0_OR_GREATER
// We need to deserialize only the first top-level object as a workaround for a common LLM backend
// issue. GPT 3.5 Turbo commonly returns multiple top-level objects after doing a function call.
// See https://community.openai.com/t/2-json-objects-returned-when-using-function-calling-and-json-mode/574348
@@ -201,14 +199,16 @@ public class AgentRunResponse
try
{
var utf8SpanLength = Encoding.UTF8.GetBytes(json, 0, json.Length, buffer, 0);
var utf8Span = new ReadOnlySpan<byte>(buffer, 0, utf8SpanLength);
var reader = new Utf8JsonReader(utf8Span, s_allowMultipleValuesJsonReaderOptions);
var reader = new Utf8JsonReader(new ReadOnlySpan<byte>(buffer, 0, utf8SpanLength), new() { AllowMultipleValues = true });
return JsonSerializer.Deserialize(ref reader, typeInfo);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
#else
return JsonSerializer.Deserialize(json, typeInfo);
#endif
}
private T? GetResultCore<T>(JsonSerializerOptions serializerOptions, out FailureReason? failureReason)
@@ -9,6 +9,7 @@
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
@@ -53,12 +53,9 @@ public class CopilotStudioAgent : AIAgent
Throw.IfNull(messages);
// Ensure that we have a valid thread to work with.
// If the thread ID is null, we need to start a new conversation and set the thread ID accordingly.
CopilotStudioAgentThread copilotStudioAgentThread = base.ValidateOrCreateThreadType(thread, () => new CopilotStudioAgentThread());
if (copilotStudioAgentThread.Id is null)
{
// If the thread ID is null, we need to start a new conversation and set the thread ID accordingly.
copilotStudioAgentThread.Id = await this.StartNewConversationAsync(cancellationToken).ConfigureAwait(false);
}
copilotStudioAgentThread.Id ??= await this.StartNewConversationAsync(cancellationToken).ConfigureAwait(false);
// Invoke the Copilot Studio agent with the provided messages.
string question = string.Join("\n", messages.Select(m => m.Text));
@@ -89,12 +86,9 @@ public class CopilotStudioAgent : AIAgent
Throw.IfNull(messages);
// Ensure that we have a valid thread to work with.
// If the thread ID is null, we need to start a new conversation and set the thread ID accordingly.
CopilotStudioAgentThread copilotStudioAgentThread = base.ValidateOrCreateThreadType(thread, () => new CopilotStudioAgentThread());
if (copilotStudioAgentThread.Id is null)
{
// If the thread ID is null, we need to start a new conversation and set the thread ID accordingly.
copilotStudioAgentThread.Id = await this.StartNewConversationAsync(cancellationToken).ConfigureAwait(false);
}
copilotStudioAgentThread.Id ??= await this.StartNewConversationAsync(cancellationToken).ConfigureAwait(false);
// Invoke the Copilot Studio agent with the provided messages.
string question = string.Join("\n", messages.Select(m => m.Text));
@@ -5,6 +5,4 @@ namespace Microsoft.Extensions.AI.Agents.CopilotStudio;
/// <summary>
/// Represents a thread for interacting with a Copilot Studio agent.
/// </summary>
public class CopilotStudioAgentThread : AgentThread
{
}
public class CopilotStudioAgentThread : AgentThread;
@@ -3,6 +3,7 @@
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI.Agents.Runtime;
@@ -21,6 +22,8 @@ public sealed class IdProxyActor : IRuntimeActor
/// </summary>
public IdProxyActor(IAgentRuntime runtime, ActorId actorId)
{
Throw.IfNull(runtime);
this.Id = actorId;
this._runtime = runtime;
}
@@ -73,6 +73,8 @@ public sealed partial class InProcessRuntime : IAgentRuntime, IAsyncDisposable
/// <inheritdoc/>
public ValueTask PublishMessageAsync(object message, TopicId topic, ActorId? sender = null, string? messageId = null, CancellationToken cancellationToken = default)
{
Throw.IfNull(message);
MessageToProcess m = new(this, message, messageId, sender, topic, cancellationToken);
this.IncrementRemainingWork();
@@ -84,6 +86,8 @@ public sealed partial class InProcessRuntime : IAgentRuntime, IAsyncDisposable
/// <inheritdoc/>
public ValueTask<object?> SendMessageAsync(object message, ActorId recipient, ActorId? sender = null, string? messageId = null, CancellationToken cancellationToken = default)
{
Throw.IfNull(message);
MessageToProcess m = new(this, message, messageId, sender, recipient, cancellationToken);
this.IncrementRemainingWork();
@@ -140,6 +144,7 @@ public sealed partial class InProcessRuntime : IAgentRuntime, IAsyncDisposable
/// <inheritdoc/>
public ValueTask AddSubscriptionAsync(ISubscriptionDefinition subscription, CancellationToken cancellationToken = default)
{
Throw.IfNull(subscription);
ThrowIfInvalid(this._subscriptions.ContainsKey(subscription.Id), "Subscription with the specified ID already exists.");
this._subscriptions.Add(subscription.Id, subscription);
@@ -150,6 +155,7 @@ public sealed partial class InProcessRuntime : IAgentRuntime, IAsyncDisposable
/// <inheritdoc/>
public ValueTask RemoveSubscriptionAsync(string subscriptionId, CancellationToken cancellationToken = default)
{
Throw.IfNull(subscriptionId);
ThrowIfInvalid(!this._subscriptions.ContainsKey(subscriptionId), "Subscription with the specified ID does not exist.");
this._subscriptions.Remove(subscriptionId);
@@ -187,6 +193,7 @@ public sealed partial class InProcessRuntime : IAgentRuntime, IAsyncDisposable
/// <inheritdoc/>
public async ValueTask<ActorType> RegisterActorFactoryAsync(ActorType type, Func<ActorId, IAgentRuntime, ValueTask<IRuntimeActor>> factoryFunc, CancellationToken cancellationToken = default)
{
Throw.IfNull(factoryFunc);
ThrowIfInvalid(this._actorFactories.ContainsKey(type), "Actor type already registered.");
this._actorFactories.Add(type, factoryFunc);
@@ -1,7 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using System.Threading;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI.Agents.Runtime;
@@ -21,15 +23,7 @@ public sealed class MessageContext
public string MessageId
{
get => this._messageId ?? Interlocked.CompareExchange(ref this._messageId, Guid.NewGuid().ToString(), null) ?? this._messageId;
set
{
if (string.IsNullOrEmpty(value))
{
throw new ArgumentException("MessageId cannot be null or empty.", nameof(value));
}
this._messageId = value;
}
set => this._messageId = Throw.IfNullOrEmpty(value);
}
/// <summary>
@@ -48,4 +42,7 @@ public sealed class MessageContext
/// Gets or sets a value indicating whether this message is part of an RPC (Remote Procedure Call).
/// </summary>
public bool IsRpc { get; set; }
/// <summary>Gets or sets the serializer options to be used when performing JSON serialization associated with this message.</summary>
public JsonSerializerOptions? SerializerOptions { get; set; }
}
@@ -57,6 +57,8 @@ public abstract class RuntimeActor : IRuntimeActor
string? description = null,
ILogger? logger = null)
{
Throw.IfNull(runtime);
this.Id = id;
this._runtime = runtime;
this.Logger = logger ?? NullLogger.Instance;
@@ -3,6 +3,7 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Text.RegularExpressions;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI.Agents.Runtime;
@@ -32,14 +33,11 @@ public readonly partial struct TopicId : IEquatable<TopicId>
/// <param name="source">The source of the event.</param>
public TopicId(string type, string? source = null)
{
if (type is null)
{
throw new ArgumentNullException(nameof(type));
}
Throw.IfNull(type);
if (!TypeRegex().IsMatch(type))
{
throw new ArgumentException("Invalid type format.", nameof(type));
Throw.ArgumentException(nameof(type), "Invalid type format.");
}
// TODO: What validation should be performed on source? The cited cloudevents spec suggests it should be a URI reference.
@@ -2,6 +2,7 @@
using System;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI.Agents.Runtime;
@@ -28,6 +29,8 @@ public sealed class TypeSubscription : ISubscriptionDefinition
/// <param name="id">Unique identifier for the subscription. If not provided, a new UUID will be generated.</param>
public TypeSubscription(string topicType, ActorType actorType, string? id = null)
{
Throw.IfNullOrEmpty(topicType);
this.TopicType = topicType;
this.ActorType = actorType;
this.Id = id ?? Guid.NewGuid().ToString();
@@ -53,10 +56,7 @@ public sealed class TypeSubscription : ISubscriptionDefinition
/// </summary>
/// <param name="topic">The topic to check.</param>
/// <returns><c>true</c> if the topic's type matches exactly, <c>false</c> otherwise.</returns>
public bool Matches(TopicId topic)
{
return topic.Type == this.TopicType;
}
public bool Matches(TopicId topic) => topic.Type == this.TopicType;
/// <summary>
/// Maps a <see cref="TopicId"/> to an <see cref="ActorId"/>. Should only be called if <see cref="Matches"/> returns true.
@@ -79,14 +79,9 @@ public sealed class TypeSubscription : ISubscriptionDefinition
/// </summary>
/// <param name="obj">The object to compare with the current instance.</param>
/// <returns><c>true</c> if the specified object is equal to this instance; otherwise, <c>false</c>.</returns>
public override bool Equals([NotNullWhen(true)] object? obj)
{
return
obj is TypeSubscription other &&
(this.Id == other.Id ||
(this.ActorType == other.ActorType &&
this.TopicType == other.TopicType));
}
public override bool Equals([NotNullWhen(true)] object? obj) =>
obj is TypeSubscription other &&
(this.Id == other.Id || (this.ActorType == other.ActorType && this.TopicType == other.TopicType));
/// <summary>
/// Determines whether the specified subscription is equal to the current subscription.
@@ -99,8 +94,5 @@ public sealed class TypeSubscription : ISubscriptionDefinition
/// Returns a hash code for this instance.
/// </summary>
/// <returns>A hash code for this instance, suitable for use in hashing algorithms and data structures.</returns>
public override int GetHashCode()
{
return HashCode.Combine(this.Id, this.ActorType, this.TopicType);
}
public override int GetHashCode() => HashCode.Combine(this.Id, this.ActorType, this.TopicType);
}
@@ -360,12 +360,9 @@ public sealed class ChatClientAgent : AIAgent
private void UpdateThreadWithTypeAndConversationId(ChatClientAgentThread chatClientThread, string? responseConversationId)
{
// Set the thread's storage location, the first time that we use it.
if (chatClientThread.StorageLocation is null)
{
chatClientThread.StorageLocation = string.IsNullOrWhiteSpace(responseConversationId)
? ChatClientAgentThreadType.InMemoryMessages
: ChatClientAgentThreadType.ConversationId;
}
chatClientThread.StorageLocation ??= string.IsNullOrWhiteSpace(responseConversationId)
? ChatClientAgentThreadType.InMemoryMessages
: ChatClientAgentThreadType.ConversationId;
// If we got a conversation id back from the chat client, it means that the service supports server side thread storage
// so we should capture the id and update the thread with the new id.
@@ -6,6 +6,7 @@ using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
namespace Microsoft.Agents.Orchestration.UnitTest;
@@ -72,7 +73,7 @@ public class DefaultTransformsTests
ChatMessage message = result.First();
Assert.Equal(ChatRole.User, message.Role);
string expectedJson = JsonSerializer.Serialize(input);
string expectedJson = JsonSerializer.Serialize(input, AgentAbstractionsJsonUtilities.DefaultOptions);
Assert.Equal(expectedJson, message.Text);
}
@@ -13,9 +13,7 @@ public class AgentRunOptionsTests
public void CloningConstructorCopiesProperties()
{
// Arrange
var options = new AgentRunOptions
{
};
var options = new AgentRunOptions();
// Act
var clone = new AgentRunOptions(options);
@@ -145,7 +145,7 @@ public class AgentRunResponseUpdateExtensionsTests
public async Task ToAgentRunResponseCoalescesTextContentAndTextReasoningContentSeparatelyAsync(bool useAsync)
{
AgentRunResponseUpdate[] updates =
{
[
new(null, "A"),
new(null, "B"),
new(null, "C"),
@@ -162,7 +162,7 @@ public class AgentRunResponseUpdateExtensionsTests
new(null, "N"),
new() { Contents = [new TextReasoningContent("O")] },
new() { Contents = [new TextReasoningContent("P")] },
};
];
AgentRunResponse response = useAsync ? await YieldAsync(updates).ToAgentRunResponseAsync() : updates.ToAgentRunResponse();
ChatMessage message = Assert.Single(response.Messages);
@@ -181,11 +181,11 @@ public class AgentRunResponseUpdateExtensionsTests
public async Task ToAgentRunResponseUsesContentExtractedFromContentsAsync()
{
AgentRunResponseUpdate[] updates =
{
[
new(null, "Hello, "),
new(null, "world!"),
new() { Contents = [new UsageContent(new() { TotalTokenCount = 42 })] },
};
];
AgentRunResponse response = await YieldAsync(updates).ToAgentRunResponseAsync();
@@ -42,7 +42,7 @@ public sealed class MockAgent : TestAgent
public override ValueTask LoadStateAsync(JsonElement state, CancellationToken cancellationToken = default)
{
this.ReceivedMessages = JsonSerializer.Deserialize<List<object>>(state) ?? throw new InvalidOperationException("Failed to deserialize state");
this.ReceivedMessages = state.Deserialize<List<object>>() ?? throw new InvalidOperationException("Failed to deserialize state");
return default;
}
}
@@ -40,9 +40,7 @@ public class ChatClientAgentRunOptionsTests
public void ConstructorCopiesPropertiesFromSourceAgentRunOptions()
{
// Arrange
var sourceRunOptions = new AgentRunOptions
{
};
var sourceRunOptions = new AgentRunOptions();
var chatOptions = new ChatOptions { MaxOutputTokens = 200 };
// Act
@@ -59,9 +57,7 @@ public class ChatClientAgentRunOptionsTests
public void ConstructorWorksWithSourceButNullChatOptions()
{
// Arrange
var sourceRunOptions = new AgentRunOptions
{
};
var sourceRunOptions = new AgentRunOptions();
// Act
var runOptions = new ChatClientAgentRunOptions(sourceRunOptions, null);