mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6332fbc639 | ||
|
|
7a0e5162a5 |
@@ -18,6 +18,7 @@ using OpenTelemetry.Trace;
|
||||
|
||||
#region Setup Telemetry
|
||||
|
||||
// Source name for this sample's custom ActivitySource and Meter; other instrumentation uses their own sources/categories.
|
||||
const string SourceName = "OpenTelemetryAspire.ConsoleApp";
|
||||
const string ServiceName = "AgentOpenTelemetry";
|
||||
|
||||
@@ -40,7 +41,6 @@ var resource = ResourceBuilder.CreateDefault()
|
||||
var tracerProviderBuilder = Sdk.CreateTracerProviderBuilder()
|
||||
.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(ServiceName, serviceVersion: "1.0.0"))
|
||||
.AddSource(SourceName) // Our custom activity source
|
||||
.AddSource("*Microsoft.Agents.AI") // Agent Framework telemetry
|
||||
.AddHttpClientInstrumentation() // Capture HTTP calls to OpenAI
|
||||
.AddOtlpExporter(options => options.Endpoint = new Uri(otlpEndpoint));
|
||||
|
||||
@@ -54,8 +54,7 @@ using var tracerProvider = tracerProviderBuilder.Build();
|
||||
// Setup metrics with resource and instrument name filtering
|
||||
using var meterProvider = Sdk.CreateMeterProviderBuilder()
|
||||
.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(ServiceName, serviceVersion: "1.0.0"))
|
||||
.AddMeter(SourceName) // Our custom meter
|
||||
.AddMeter("*Microsoft.Agents.AI") // Agent Framework metrics
|
||||
.AddMeter(SourceName) // Our custom meter source
|
||||
.AddHttpClientInstrumentation() // HTTP client metrics
|
||||
.AddRuntimeInstrumentation() // .NET runtime metrics
|
||||
.AddOtlpExporter(options => options.Endpoint = new Uri(otlpEndpoint))
|
||||
@@ -128,7 +127,7 @@ var agent = new ChatClientAgent(instrumentedChatClient,
|
||||
instructions: "You are a helpful assistant that provides concise and informative responses.",
|
||||
tools: [AIFunctionFactory.Create(GetWeatherAsync)])
|
||||
.AsBuilder()
|
||||
.UseOpenTelemetry(SourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the agent level
|
||||
.UseOpenTelemetry(sourceName: SourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the agent level
|
||||
.Build();
|
||||
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
@@ -9,7 +9,7 @@ using Microsoft.Extensions.AI;
|
||||
namespace WorkflowAsAnAgentSample;
|
||||
|
||||
/// <summary>
|
||||
/// This sample introduces the concept of workflows as agents, where a workflow can be
|
||||
/// This sample introduces the concepts workflows as agents, where a workflow can be
|
||||
/// treated as an <see cref="AIAgent"/>. This allows you to interact with a workflow
|
||||
/// as if it were a single agent.
|
||||
///
|
||||
@@ -18,14 +18,6 @@ namespace WorkflowAsAnAgentSample;
|
||||
///
|
||||
/// You will interact with the workflow in an interactive loop, sending messages and receiving
|
||||
/// streaming responses from the workflow as if it were an agent who responds in both languages.
|
||||
///
|
||||
/// This sample also demonstrates <see cref="IResettableExecutor"/>, which is required
|
||||
/// for stateful executors that are shared across multiple workflow runs. Each iteration
|
||||
/// of the interactive loop triggers a new workflow run against the same workflow instance.
|
||||
/// Between runs, the framework automatically calls <see cref="IResettableExecutor.ResetAsync"/>
|
||||
/// on shared executors so that accumulated state (e.g., collected messages) is cleared
|
||||
/// before the next run begins. See <c>WorkflowFactory.ConcurrentAggregationExecutor</c>
|
||||
/// for the implementation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Pre-requisites:
|
||||
@@ -47,10 +39,7 @@ public static class Program
|
||||
var agent = workflow.AsAIAgent("workflow-agent", "Workflow Agent");
|
||||
var session = await agent.CreateSessionAsync();
|
||||
|
||||
// Start an interactive loop to interact with the workflow as if it were an agent.
|
||||
// Each iteration runs the workflow again on the same workflow instance. Between runs,
|
||||
// the framework calls IResettableExecutor.ResetAsync() on shared stateful executors
|
||||
// (like ConcurrentAggregationExecutor) to clear accumulated state from the previous run.
|
||||
// Start an interactive loop to interact with the workflow as if it were an agent
|
||||
while (true)
|
||||
{
|
||||
Console.WriteLine();
|
||||
|
||||
@@ -10,14 +10,6 @@ internal static class WorkflowFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a workflow that uses two language agents to process input concurrently.
|
||||
///
|
||||
/// In this workflow, the <c>Start</c> <see cref="ChatForwardingExecutor"/> and the
|
||||
/// <see cref="ConcurrentAggregationExecutor"/> are provided as shared instances, meaning
|
||||
/// the same executor objects are reused across multiple workflow runs. The language agents
|
||||
/// (French and English) are created via a factory and instantiated per workflow run.
|
||||
/// Stateful shared executors must implement <see cref="IResettableExecutor"/> so the
|
||||
/// framework can clear their state between runs. Framework-provided executors like
|
||||
/// <see cref="ChatForwardingExecutor"/> already implement this interface.
|
||||
/// </summary>
|
||||
/// <param name="chatClient">The chat client to use for the agents</param>
|
||||
/// <returns>A workflow that processes input using two language agents</returns>
|
||||
@@ -48,16 +40,6 @@ internal static class WorkflowFactory
|
||||
|
||||
/// <summary>
|
||||
/// Executor that aggregates the results from the concurrent agents.
|
||||
///
|
||||
/// This executor is stateful — it accumulates messages in <see cref="_messages"/>
|
||||
/// as they arrive from each agent. Because it is provided as a shared instance
|
||||
/// (not via a factory), the same object is reused across workflow runs. Implementing
|
||||
/// <see cref="IResettableExecutor"/> allows the framework to call <see cref="ResetAsync"/>
|
||||
/// between runs, clearing accumulated state so each run starts fresh.
|
||||
///
|
||||
/// Without <see cref="IResettableExecutor"/>, attempting to reuse a workflow containing
|
||||
/// shared executor instances that do not implement this interface would throw an
|
||||
/// <see cref="InvalidOperationException"/>.
|
||||
/// </summary>
|
||||
[YieldsOutput(typeof(string))]
|
||||
private sealed class ConcurrentAggregationExecutor() :
|
||||
@@ -83,11 +65,7 @@ internal static class WorkflowFactory
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the executor state between workflow runs by clearing accumulated messages.
|
||||
/// The framework calls this automatically when a workflow run completes, before the
|
||||
/// workflow can be used for another run.
|
||||
/// </summary>
|
||||
/// <inheritdoc/>
|
||||
public ValueTask ResetAsync()
|
||||
{
|
||||
this._messages.Clear();
|
||||
|
||||
@@ -38,7 +38,6 @@ internal static class SourceBuilder
|
||||
sb.AppendLine("using System.Collections.Generic;");
|
||||
sb.AppendLine("using Microsoft.Agents.AI.Workflows;");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("using RouteBuilder = Microsoft.Agents.AI.Workflows.RouteBuilder;");
|
||||
|
||||
// Namespace
|
||||
if (!string.IsNullOrWhiteSpace(info.Namespace))
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ namespace Microsoft.Agents.AI.Workflows;
|
||||
/// Represents a configuration for an object with a string identifier. For example, <see cref="IIdentified"/> object.
|
||||
/// </summary>
|
||||
/// <param name="id">A unique identifier for the configurable object.</param>
|
||||
public class ExecutorConfig(string id)
|
||||
public class Config(string id)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a unique identifier for the configurable object.
|
||||
@@ -23,7 +23,7 @@ public class ExecutorConfig(string id)
|
||||
/// <typeparam name="TOptions">The type of options for the configurable object.</typeparam>
|
||||
/// <param name="id">A unique identifier for the configurable object.</param>
|
||||
/// <param name="options">The options for the configurable object.</param>
|
||||
public class ExecutorConfig<TOptions>(string id, TOptions? options = default) : ExecutorConfig(id)
|
||||
public class Config<TOptions>(string id, TOptions? options = default) : Config(id)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the options for the configured object.
|
||||
@@ -3,9 +3,9 @@
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for creating <see cref="Configured{TSubject}"/> objects
|
||||
/// Provides extensions methods for creating <see cref="Configured{TSubject}"/> objects
|
||||
/// </summary>
|
||||
internal static class ConfigurationExtensions
|
||||
public static class ConfigurationExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new configuration that treats the subject as its base type, allowing configuration to be applied at
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace Microsoft.Agents.AI.Workflows;
|
||||
/// <summary>
|
||||
/// Provides methods for creating <see cref="Configured{TSubject}"/> instances.
|
||||
/// </summary>
|
||||
internal static class Configured
|
||||
public static class Configured
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a <see cref="Configured{TSubject}"/> instance from an existing subject instance.
|
||||
@@ -50,10 +50,10 @@ internal static class Configured
|
||||
/// A representation of a preconfigured, lazy-instantiatable instance of <typeparamref name="TSubject"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TSubject">The type of the preconfigured subject.</typeparam>
|
||||
/// <param name="factoryAsync">A factory to instantiate the subject when desired.</param>
|
||||
/// <param name="factoryAsync">A factory to intantiate the subject when desired.</param>
|
||||
/// <param name="id">The unique identifier for the configured subject.</param>
|
||||
/// <param name="raw"></param>
|
||||
internal class Configured<TSubject>(Func<ExecutorConfig, string, ValueTask<TSubject>> factoryAsync, string id, object? raw = null)
|
||||
public class Configured<TSubject>(Func<Config, string, ValueTask<TSubject>> factoryAsync, string id, object? raw = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the raw representation of the configured object, if any.
|
||||
@@ -66,14 +66,14 @@ internal class Configured<TSubject>(Func<ExecutorConfig, string, ValueTask<TSubj
|
||||
public string Id => id;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the factory function to create an instance of <typeparamref name="TSubject"/> given a <see cref="ExecutorConfig"/>.
|
||||
/// Gets the factory function to create an instance of <typeparamref name="TSubject"/> given a <see cref="Config"/>.
|
||||
/// </summary>
|
||||
public Func<ExecutorConfig, string, ValueTask<TSubject>> FactoryAsync => factoryAsync;
|
||||
public Func<Config, string, ValueTask<TSubject>> FactoryAsync => factoryAsync;
|
||||
|
||||
/// <summary>
|
||||
/// The configuration for this configured instance.
|
||||
/// </summary>
|
||||
public ExecutorConfig Configuration => new(this.Id);
|
||||
public Config Configuration => new(this.Id);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a "partially" applied factory function that only requires no parameters to create an instance of
|
||||
@@ -87,11 +87,11 @@ internal class Configured<TSubject>(Func<ExecutorConfig, string, ValueTask<TSubj
|
||||
/// </summary>
|
||||
/// <typeparam name="TSubject">The type of the preconfigured subject.</typeparam>
|
||||
/// <typeparam name="TOptions">The type of configuration options for the preconfigured subject.</typeparam>
|
||||
/// <param name="factoryAsync">A factory to instantiate the subject when desired.</param>
|
||||
/// <param name="factoryAsync">A factory to intantiate the subject when desired.</param>
|
||||
/// <param name="id">The unique identifier for the configured subject.</param>
|
||||
/// <param name="options">Additional configuration options for the subject.</param>
|
||||
/// <param name="raw"></param>
|
||||
internal class Configured<TSubject, TOptions>(Func<ExecutorConfig<TOptions>, string, ValueTask<TSubject>> factoryAsync, string id, TOptions? options = default, object? raw = null)
|
||||
public class Configured<TSubject, TOptions>(Func<Config<TOptions>, string, ValueTask<TSubject>> factoryAsync, string id, TOptions? options = default, object? raw = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// The raw representation of the configured object, if any.
|
||||
@@ -109,14 +109,14 @@ internal class Configured<TSubject, TOptions>(Func<ExecutorConfig<TOptions>, str
|
||||
public TOptions? Options => options;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the factory function to create an instance of <typeparamref name="TSubject"/> given a <see cref="ExecutorConfig{TOptions}"/>.
|
||||
/// Gets the factory function to create an instance of <typeparamref name="TSubject"/> given a <see cref="Config{TOptions}"/>.
|
||||
/// </summary>
|
||||
public Func<ExecutorConfig<TOptions>, string, ValueTask<TSubject>> FactoryAsync => factoryAsync;
|
||||
public Func<Config<TOptions>, string, ValueTask<TSubject>> FactoryAsync => factoryAsync;
|
||||
|
||||
/// <summary>
|
||||
/// The configuration for this configured instance.
|
||||
/// </summary>
|
||||
public ExecutorConfig<TOptions> Configuration => new(this.Id, this.Options);
|
||||
public Config<TOptions> Configuration => new(this.Id, this.Options);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a "partially" applied factory function that only requires no parameters to create an instance of
|
||||
@@ -124,11 +124,11 @@ internal class Configured<TSubject, TOptions>(Func<ExecutorConfig<TOptions>, str
|
||||
/// </summary>
|
||||
internal Func<string, ValueTask<TSubject>> BoundFactoryAsync => (sessionId) => this.CreateValidatingMemoizedFactory()(this.Configuration, sessionId);
|
||||
|
||||
private Func<ExecutorConfig, string, ValueTask<TSubject>> CreateValidatingMemoizedFactory()
|
||||
private Func<Config, string, ValueTask<TSubject>> CreateValidatingMemoizedFactory()
|
||||
{
|
||||
return FactoryAsync;
|
||||
|
||||
async ValueTask<TSubject> FactoryAsync(ExecutorConfig configuration, string sessionId)
|
||||
async ValueTask<TSubject> FactoryAsync(Config configuration, string sessionId)
|
||||
{
|
||||
if (this.Id != configuration.Id)
|
||||
{
|
||||
|
||||
@@ -53,9 +53,6 @@ internal sealed class AsyncRunHandle : ICheckpointingHandle, IAsyncDisposable
|
||||
public ValueTask<RunStatus> GetStatusAsync(CancellationToken cancellationToken = default)
|
||||
=> this._eventStream.GetStatusAsync(cancellationToken);
|
||||
|
||||
internal bool TryGetResponsePortExecutorId(string portId, out string? executorId)
|
||||
=> this._stepRunner.TryGetResponsePortExecutorId(portId, out executorId);
|
||||
|
||||
public async IAsyncEnumerable<WorkflowEvent> TakeEventStreamAsync(bool blockOnPendingRequest, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
//Debug.Assert(breakOnHalt);
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -96,18 +95,6 @@ internal sealed class EdgeMap
|
||||
return portRunner.ChaseEdgeAsync(new MessageEnvelope(response, ExecutorIdentity.None), this._stepTracer, cancellationToken);
|
||||
}
|
||||
|
||||
internal bool TryGetResponsePortExecutorId(string portId, [NotNullWhen(true)] out string? executorId)
|
||||
{
|
||||
if (this._portEdgeRunners.TryGetValue(portId, out ResponseEdgeRunner? portRunner))
|
||||
{
|
||||
executorId = portRunner.ExecutorId;
|
||||
return true;
|
||||
}
|
||||
|
||||
executorId = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
internal async ValueTask<Dictionary<EdgeId, PortableValue>> ExportStateAsync()
|
||||
{
|
||||
Dictionary<EdgeId, PortableValue> exportedStates = [];
|
||||
|
||||
@@ -19,7 +19,6 @@ internal interface ISuperStepRunner
|
||||
bool HasUnprocessedMessages { get; }
|
||||
|
||||
ValueTask EnqueueResponseAsync(ExternalResponse response, CancellationToken cancellationToken = default);
|
||||
bool TryGetResponsePortExecutorId(string portId, out string? executorId);
|
||||
|
||||
ValueTask<bool> IsValidInputTypeAsync<T>(CancellationToken cancellationToken = default);
|
||||
ValueTask<bool> EnqueueMessageAsync<T>(T message, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -106,7 +106,8 @@ internal sealed class StateManager
|
||||
if (typeof(T) == typeof(object))
|
||||
{
|
||||
// Reading as object will break across serialize/deserialize boundaries, e.g. checkpointing, distributed runtime, etc.
|
||||
throw new NotSupportedException("Reading state as 'object' is not supported. Use 'PortableValue' instead for variants.");
|
||||
// Disabled pending upstream updates for this change; see https://github.com/microsoft/agent-framework/issues/1369
|
||||
//throw new NotSupportedException("Reading state as 'object' is not supported. Use 'PortableValue' instead for variants.");
|
||||
}
|
||||
|
||||
Throw.IfNullOrEmpty(key);
|
||||
|
||||
@@ -113,7 +113,7 @@ public static class ExecutorBindingExtensions
|
||||
/// <param name="id">An id for the executor to be instantiated.</param>
|
||||
/// <param name="options">An optional parameter specifying the options.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
|
||||
public static ExecutorBinding BindExecutor<TExecutor, TOptions>(this Func<ExecutorConfig<TOptions>, string, ValueTask<TExecutor>> factoryAsync, string id, TOptions? options = null)
|
||||
public static ExecutorBinding BindExecutor<TExecutor, TOptions>(this Func<Config<TOptions>, string, ValueTask<TExecutor>> factoryAsync, string id, TOptions? options = null)
|
||||
where TExecutor : Executor
|
||||
where TOptions : ExecutorOptions
|
||||
{
|
||||
@@ -139,7 +139,7 @@ public static class ExecutorBindingExtensions
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
|
||||
[Obsolete("Use BindExecutor() instead")]
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public static ExecutorBinding ConfigureFactory<TExecutor, TOptions>(this Func<ExecutorConfig<TOptions>, string, ValueTask<TExecutor>> factoryAsync, string id, TOptions? options = null)
|
||||
public static ExecutorBinding ConfigureFactory<TExecutor, TOptions>(this Func<Config<TOptions>, string, ValueTask<TExecutor>> factoryAsync, string id, TOptions? options = null)
|
||||
where TExecutor : Executor
|
||||
where TOptions : ExecutorOptions
|
||||
=> factoryAsync.BindExecutor(id, options);
|
||||
|
||||
@@ -13,18 +13,10 @@ namespace Microsoft.Agents.AI.Workflows;
|
||||
/// </summary>
|
||||
public sealed class HandoffsWorkflowBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// The prefix for function calls that trigger handoffs to other agents; the full name is then `{FunctionPrefix}<agent_id>`,
|
||||
/// where `<agent_id>` is the ID of the target agent to hand off to.
|
||||
/// </summary>
|
||||
public const string FunctionPrefix = "handoff_to_";
|
||||
|
||||
internal const string FunctionPrefix = "handoff_to_";
|
||||
private readonly AIAgent _initialAgent;
|
||||
private readonly Dictionary<AIAgent, HashSet<HandoffTarget>> _targets = [];
|
||||
private readonly HashSet<AIAgent> _allAgents = new(AIAgentIDEqualityComparer.Instance);
|
||||
|
||||
private bool _emitAgentResponseEvents;
|
||||
private bool _emitAgentResponseUpdateEvents;
|
||||
private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly;
|
||||
|
||||
/// <summary>
|
||||
@@ -55,13 +47,9 @@ public sealed class HandoffsWorkflowBuilder
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// Sets instructions to provide to each agent that has handoffs about how and when to perform them.
|
||||
/// Sets additional instructions to provide to an agent that has handoffs about how and when to
|
||||
/// perform them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// In the vast majority of cases, the <see cref="DefaultHandoffInstructions"/> will be sufficient, and there will be no need to customize.
|
||||
/// If you do provide alternate instructions, remember to explain the mechanics of the handoff function tool call, using see
|
||||
/// <see cref="FunctionPrefix"/> constant.
|
||||
/// </remarks>
|
||||
/// <param name="instructions">The instructions to provide, or <see langword="null"/> to restore the default instructions.</param>
|
||||
public HandoffsWorkflowBuilder WithHandoffInstructions(string? instructions)
|
||||
{
|
||||
@@ -69,29 +57,6 @@ public sealed class HandoffsWorkflowBuilder
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a value indicating whether agent streaming update events should be emitted during execution.
|
||||
/// If <see langword="null"/>, the value will be taken from the <see cref="TurnToken"/>
|
||||
/// </summary>
|
||||
/// <param name="emitAgentResponseUpdateEvents"></param>
|
||||
/// <returns></returns>
|
||||
public HandoffsWorkflowBuilder EmitAgentResponseUpdateEvents(bool emitAgentResponseUpdateEvents = true)
|
||||
{
|
||||
this._emitAgentResponseUpdateEvents = emitAgentResponseUpdateEvents;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a value indicating whether aggregated agent response events should be emitted during execution.
|
||||
/// </summary>
|
||||
/// <param name="emitAgentResponseEvents"></param>
|
||||
/// <returns></returns>
|
||||
public HandoffsWorkflowBuilder EmitAgentResponseEvents(bool emitAgentResponseEvents = true)
|
||||
{
|
||||
this._emitAgentResponseEvents = emitAgentResponseEvents;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the behavior for filtering <see cref="FunctionCallContent"/> and <see cref="ChatRole.Tool"/> contents from
|
||||
/// <see cref="ChatMessage"/>s flowing through the handoff workflow. Defaults to <see cref="HandoffToolCallFilteringBehavior.HandoffOnly"/>.
|
||||
@@ -210,10 +175,7 @@ public sealed class HandoffsWorkflowBuilder
|
||||
HandoffsEndExecutor end = new();
|
||||
WorkflowBuilder builder = new(start);
|
||||
|
||||
HandoffAgentExecutorOptions options = new(this.HandoffInstructions,
|
||||
this._emitAgentResponseEvents,
|
||||
this._emitAgentResponseUpdateEvents,
|
||||
this._toolCallFilteringBehavior);
|
||||
HandoffAgentExecutorOptions options = new(this.HandoffInstructions, this._toolCallFilteringBehavior);
|
||||
|
||||
// Create an AgentExecutor for each again.
|
||||
Dictionary<string, HandoffAgentExecutor> executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, options));
|
||||
|
||||
@@ -160,8 +160,6 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle
|
||||
|
||||
bool ISuperStepRunner.HasUnservicedRequests => this.RunContext.HasUnservicedRequests;
|
||||
bool ISuperStepRunner.HasUnprocessedMessages => this.RunContext.NextStepHasActions;
|
||||
bool ISuperStepRunner.TryGetResponsePortExecutorId(string portId, out string? executorId)
|
||||
=> this.RunContext.TryGetResponsePortExecutorId(portId, out executorId);
|
||||
|
||||
public bool IsCheckpointingEnabled => this.RunContext.IsCheckpointingEnabled;
|
||||
|
||||
|
||||
@@ -296,9 +296,6 @@ internal sealed class InProcessRunnerContext : IRunnerContext
|
||||
return this._externalRequests.TryRemove(requestId, out _);
|
||||
}
|
||||
|
||||
internal bool TryGetResponsePortExecutorId(string portId, [NotNullWhen(true)] out string? executorId)
|
||||
=> this._edgeMap.TryGetResponsePortExecutorId(portId, out executorId);
|
||||
|
||||
private IEventSink OutgoingEvents { get; }
|
||||
|
||||
internal StateManager StateManager { get; } = new();
|
||||
|
||||
@@ -12,15 +12,6 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
|
||||
|
||||
internal record AIAgentHostState(JsonElement? ThreadState, bool? CurrentTurnEmitEvents);
|
||||
|
||||
internal static class TurnExtensions
|
||||
{
|
||||
public static bool ShouldEmitStreamingEvents(this TurnToken token, bool? agentSetting)
|
||||
=> token.EmitEvents ?? agentSetting ?? false;
|
||||
|
||||
public static bool ShouldEmitStreamingEvents(bool? turnTokenSetting, bool? agentSetting)
|
||||
=> turnTokenSetting ?? agentSetting ?? false;
|
||||
}
|
||||
|
||||
internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
|
||||
{
|
||||
private readonly AIAgent _agent;
|
||||
@@ -77,17 +68,10 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
|
||||
throw new InvalidOperationException($"No pending ToolApprovalRequest found with id '{response.RequestId}'.");
|
||||
}
|
||||
|
||||
// Merge the external response with any already-buffered regular messages so mixed-content
|
||||
// resumes can be processed in one invocation.
|
||||
return this.ProcessTurnMessagesAsync(async (pendingMessages, ctx, ct) =>
|
||||
{
|
||||
pendingMessages.Add(new ChatMessage(ChatRole.User, [response]));
|
||||
List<ChatMessage> implicitTurnMessages = [new ChatMessage(ChatRole.User, [response])];
|
||||
|
||||
await this.ContinueTurnAsync(pendingMessages, ctx, this._currentTurnEmitEvents ?? false, ct).ConfigureAwait(false);
|
||||
|
||||
// Clear the buffered turn messages because they were consumed by ContinueTurnAsync.
|
||||
return null;
|
||||
}, context, cancellationToken);
|
||||
// ContinueTurnAsync owns failing to emit a TurnToken if this response does not clear up all remaining outstanding requests.
|
||||
return this.ContinueTurnAsync(implicitTurnMessages, context, this._currentTurnEmitEvents ?? false, cancellationToken);
|
||||
}
|
||||
|
||||
private ValueTask HandleFunctionResultAsync(
|
||||
@@ -100,19 +84,13 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
|
||||
throw new InvalidOperationException($"No pending FunctionCall found with id '{result.CallId}'.");
|
||||
}
|
||||
|
||||
// Merge the external response with any already-buffered regular messages so mixed-content
|
||||
// resumes can be processed in one invocation.
|
||||
return this.ProcessTurnMessagesAsync(async (pendingMessages, ctx, ct) =>
|
||||
{
|
||||
pendingMessages.Add(new ChatMessage(ChatRole.Tool, [result]));
|
||||
|
||||
await this.ContinueTurnAsync(pendingMessages, ctx, this._currentTurnEmitEvents ?? false, ct).ConfigureAwait(false);
|
||||
|
||||
// Clear the buffered turn messages because they were consumed by ContinueTurnAsync.
|
||||
return null;
|
||||
}, context, cancellationToken);
|
||||
List<ChatMessage> implicitTurnMessages = [new ChatMessage(ChatRole.Tool, [result])];
|
||||
return this.ContinueTurnAsync(implicitTurnMessages, context, this._currentTurnEmitEvents ?? false, cancellationToken);
|
||||
}
|
||||
|
||||
public bool ShouldEmitStreamingEvents(bool? emitEvents)
|
||||
=> emitEvents ?? this._options.EmitAgentUpdateEvents ?? false;
|
||||
|
||||
private async ValueTask<AgentSession> EnsureSessionAsync(IWorkflowContext context, CancellationToken cancellationToken) =>
|
||||
this._session ??= await this._agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -181,10 +159,7 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
|
||||
}
|
||||
|
||||
protected override ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
|
||||
=> this.ContinueTurnAsync(messages,
|
||||
context,
|
||||
TurnExtensions.ShouldEmitStreamingEvents(turnTokenSetting: emitEvents, this._options.EmitAgentUpdateEvents),
|
||||
cancellationToken);
|
||||
=> this.ContinueTurnAsync(messages, context, this.ShouldEmitStreamingEvents(emitEvents), cancellationToken);
|
||||
|
||||
private async ValueTask<AgentResponse> InvokeAgentAsync(IEnumerable<ChatMessage> messages, IWorkflowContext context, bool emitEvents, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -223,7 +198,7 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
|
||||
ExtractUnservicedRequests(response.Messages.SelectMany(message => message.Contents));
|
||||
}
|
||||
|
||||
if (this._options.EmitAgentResponseEvents)
|
||||
if (this._options.EmitAgentResponseEvents == true)
|
||||
{
|
||||
await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -16,12 +16,10 @@ internal sealed class AIContentExternalHandler<TRequestContent, TResponseContent
|
||||
where TResponseContent : AIContent
|
||||
{
|
||||
private readonly PortBinding? _portBinding;
|
||||
private readonly string _portId;
|
||||
private ConcurrentDictionary<string, TRequestContent> _pendingRequests = new();
|
||||
|
||||
public AIContentExternalHandler(ref ProtocolBuilder protocolBuilder, string portId, bool intercepted, Func<TResponseContent, IWorkflowContext, CancellationToken, ValueTask> handler)
|
||||
{
|
||||
this._portId = portId;
|
||||
PortBinding? portBinding = null;
|
||||
protocolBuilder = protocolBuilder.ConfigureRoutes(routeBuilder => ConfigureRoutes(routeBuilder, out portBinding));
|
||||
this._portBinding = portBinding;
|
||||
@@ -60,14 +58,12 @@ internal sealed class AIContentExternalHandler<TRequestContent, TResponseContent
|
||||
{
|
||||
if (!this._pendingRequests.TryAdd(id, requestContent))
|
||||
{
|
||||
// Request is already pending; treat as an idempotent re-emission.
|
||||
// Do not repost to the sink because request IDs must remain unique while pending.
|
||||
return default;
|
||||
throw new InvalidOperationException($"A pending request with ID '{id}' already exists.");
|
||||
}
|
||||
|
||||
return this.IsIntercepted
|
||||
? context.SendMessageAsync(requestContent, cancellationToken: cancellationToken)
|
||||
: this._portBinding.PostRequestAsync(requestContent, this.CreateExternalRequestId(id), cancellationToken);
|
||||
: this._portBinding.PostRequestAsync(requestContent, id, cancellationToken);
|
||||
}
|
||||
|
||||
public bool MarkRequestAsHandled(string id)
|
||||
@@ -78,8 +74,6 @@ internal sealed class AIContentExternalHandler<TRequestContent, TResponseContent
|
||||
[MemberNotNullWhen(false, nameof(_portBinding))]
|
||||
private bool IsIntercepted => this._portBinding == null;
|
||||
|
||||
private string CreateExternalRequestId(string requestId) => $"{this._portId.Length}:{this._portId}:{requestId}";
|
||||
|
||||
private static string MakeKey(string id) => $"{id}_PendingRequests";
|
||||
|
||||
public async ValueTask OnCheckpointingAsync(string id, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -14,20 +14,14 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
|
||||
|
||||
internal sealed class HandoffAgentExecutorOptions
|
||||
{
|
||||
public HandoffAgentExecutorOptions(string? handoffInstructions, bool emitAgentResponseEvents, bool? emitAgentResponseUpdateEvents, HandoffToolCallFilteringBehavior toolCallFilteringBehavior)
|
||||
public HandoffAgentExecutorOptions(string? handoffInstructions, HandoffToolCallFilteringBehavior toolCallFilteringBehavior)
|
||||
{
|
||||
this.HandoffInstructions = handoffInstructions;
|
||||
this.EmitAgentResponseEvents = emitAgentResponseEvents;
|
||||
this.EmitAgentResponseUpdateEvents = emitAgentResponseUpdateEvents;
|
||||
this.ToolCallFilteringBehavior = toolCallFilteringBehavior;
|
||||
}
|
||||
|
||||
public string? HandoffInstructions { get; set; }
|
||||
|
||||
public bool EmitAgentResponseEvents { get; set; }
|
||||
|
||||
public bool? EmitAgentResponseUpdateEvents { get; set; }
|
||||
|
||||
public HandoffToolCallFilteringBehavior ToolCallFilteringBehavior { get; set; } = HandoffToolCallFilteringBehavior.HandoffOnly;
|
||||
}
|
||||
|
||||
@@ -256,14 +250,7 @@ internal sealed class HandoffAgentExecutor(
|
||||
}
|
||||
}
|
||||
|
||||
AgentResponse agentResponse = updates.ToAgentResponse();
|
||||
|
||||
if (options.EmitAgentResponseEvents)
|
||||
{
|
||||
await context.YieldOutputAsync(agentResponse, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
allMessages.AddRange(agentResponse.Messages);
|
||||
allMessages.AddRange(updates.ToAgentResponse().Messages);
|
||||
|
||||
roleChanges.ResetUserToAssistantForChangedRoles();
|
||||
|
||||
@@ -272,7 +259,7 @@ internal sealed class HandoffAgentExecutor(
|
||||
async Task AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken)
|
||||
{
|
||||
updates.Add(update);
|
||||
if (message.TurnToken.ShouldEmitStreamingEvents(options.EmitAgentResponseUpdateEvents))
|
||||
if (message.TurnToken.EmitEvents is true)
|
||||
{
|
||||
await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -60,9 +60,6 @@ public sealed class StreamingRun : CheckpointableRunBase, IAsyncDisposable
|
||||
internal ValueTask<bool> TrySendMessageUntypedAsync(object message, Type? declaredType = null)
|
||||
=> this._runHandle.EnqueueMessageUntypedAsync(message, declaredType);
|
||||
|
||||
internal bool TryGetResponsePortExecutorId(string portId, out string? executorId)
|
||||
=> this._runHandle.TryGetResponsePortExecutorId(portId, out executorId);
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously streams workflow events as they occur during workflow execution.
|
||||
/// </summary>
|
||||
|
||||
@@ -43,7 +43,7 @@ internal sealed class WorkflowChatHistoryProvider : ChatHistoryProvider
|
||||
=> this._sessionState.GetOrInitializeState(session).Messages.AddRange(messages);
|
||||
|
||||
protected override ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
=> new(this._sessionState.GetOrInitializeState(context.Session).Messages.AsReadOnly());
|
||||
=> new(this._sessionState.GetOrInitializeState(context.Session).Messages);
|
||||
|
||||
protected override ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -62,12 +62,6 @@ internal sealed class WorkflowChatHistoryProvider : ChatHistoryProvider
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerable<ChatMessage> GetAllMessages(AgentSession session)
|
||||
{
|
||||
var state = this._sessionState.GetOrInitializeState(session);
|
||||
return state.Messages.AsReadOnly();
|
||||
}
|
||||
|
||||
public void UpdateBookmark(AgentSession session)
|
||||
{
|
||||
var state = this._sessionState.GetOrInitializeState(session);
|
||||
|
||||
@@ -119,17 +119,13 @@ internal sealed class WorkflowHostAgent : AIAgent
|
||||
MessageMerger merger = new();
|
||||
|
||||
await foreach (AgentResponseUpdate update in workflowSession.InvokeStageAsync(cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
.WithCancellation(cancellationToken))
|
||||
.ConfigureAwait(false)
|
||||
.WithCancellation(cancellationToken))
|
||||
{
|
||||
merger.AddUpdate(update);
|
||||
}
|
||||
|
||||
AgentResponse response = merger.ComputeMerged(workflowSession.LastResponseId!, this.Id, this.Name);
|
||||
workflowSession.ChatHistoryProvider.AddMessages(workflowSession, response.Messages);
|
||||
workflowSession.ChatHistoryProvider.UpdateBookmark(workflowSession);
|
||||
|
||||
return response;
|
||||
return merger.ComputeMerged(workflowSession.LastResponseId!, this.Id, this.Name);
|
||||
}
|
||||
|
||||
protected override async
|
||||
@@ -142,18 +138,11 @@ internal sealed class WorkflowHostAgent : AIAgent
|
||||
await this.ValidateWorkflowAsync().ConfigureAwait(false);
|
||||
|
||||
WorkflowSession workflowSession = await this.UpdateSessionAsync(messages, session, cancellationToken).ConfigureAwait(false);
|
||||
MessageMerger merger = new();
|
||||
|
||||
await foreach (AgentResponseUpdate update in workflowSession.InvokeStageAsync(cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
.WithCancellation(cancellationToken))
|
||||
{
|
||||
merger.AddUpdate(update);
|
||||
yield return update;
|
||||
}
|
||||
|
||||
AgentResponse response = merger.ComputeMerged(workflowSession.LastResponseId!, this.Id, this.Name);
|
||||
workflowSession.ChatHistoryProvider.AddMessages(workflowSession, response.Messages);
|
||||
workflowSession.ChatHistoryProvider.UpdateBookmark(workflowSession);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,25 +25,6 @@ internal sealed class WorkflowSession : AgentSession
|
||||
|
||||
private InMemoryCheckpointManager? _inMemoryCheckpointManager;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks pending external requests by their workflow-facing request ID.
|
||||
/// This mapping enables converting incoming response content back to <see cref="ExternalResponse"/>
|
||||
/// when resuming a workflow from a checkpoint.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Entries are added when a <see cref="RequestInfoEvent"/> is received during workflow execution,
|
||||
/// and removed when a matching response is delivered via <see cref="SendMessagesWithResponseConversionAsync"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The number of entries is bounded by the number of outstanding external requests in a single workflow run.
|
||||
/// When a session is abandoned, all pending requests are released with the session object.
|
||||
/// Request-level timeouts, if needed, should be implemented in the workflow definition itself
|
||||
/// (e.g., using a timer racing against an external event).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private readonly Dictionary<string, ExternalRequest> _pendingRequests = [];
|
||||
|
||||
internal static bool VerifyCheckpointingConfiguration(IWorkflowExecutionEnvironment executionEnvironment, [NotNullWhen(true)] out InProcessExecutionEnvironment? inProcEnv)
|
||||
{
|
||||
inProcEnv = null;
|
||||
@@ -109,7 +90,6 @@ internal sealed class WorkflowSession : AgentSession
|
||||
|
||||
this.LastCheckpoint = sessionState.LastCheckpoint;
|
||||
this.StateBag = sessionState.StateBag;
|
||||
this._pendingRequests = sessionState.PendingRequests ?? [];
|
||||
}
|
||||
|
||||
public CheckpointInfo? LastCheckpoint { get; set; }
|
||||
@@ -121,8 +101,7 @@ internal sealed class WorkflowSession : AgentSession
|
||||
this.SessionId,
|
||||
this.LastCheckpoint,
|
||||
this._inMemoryCheckpointManager,
|
||||
this.StateBag,
|
||||
this._pendingRequests);
|
||||
this.StateBag);
|
||||
|
||||
return marshaller.Marshal(info);
|
||||
}
|
||||
@@ -131,7 +110,7 @@ internal sealed class WorkflowSession : AgentSession
|
||||
{
|
||||
Throw.IfNullOrEmpty(parts);
|
||||
|
||||
return new(ChatRole.Assistant, parts)
|
||||
AgentResponseUpdate update = new(ChatRole.Assistant, parts)
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
@@ -139,22 +118,30 @@ internal sealed class WorkflowSession : AgentSession
|
||||
ResponseId = responseId,
|
||||
RawRepresentation = raw
|
||||
};
|
||||
|
||||
this.ChatHistoryProvider.AddMessages(this, update.ToChatMessage());
|
||||
|
||||
return update;
|
||||
}
|
||||
|
||||
public AgentResponseUpdate CreateUpdate(string responseId, object raw, ChatMessage message)
|
||||
{
|
||||
Throw.IfNull(message);
|
||||
|
||||
return new(message.Role, message.Contents)
|
||||
AgentResponseUpdate update = new(message.Role, message.Contents)
|
||||
{
|
||||
CreatedAt = message.CreatedAt ?? DateTimeOffset.UtcNow,
|
||||
MessageId = message.MessageId ?? Guid.NewGuid().ToString("N"),
|
||||
ResponseId = responseId,
|
||||
RawRepresentation = raw
|
||||
};
|
||||
|
||||
this.ChatHistoryProvider.AddMessages(this, update.ToChatMessage());
|
||||
|
||||
return update;
|
||||
}
|
||||
|
||||
private async ValueTask<ResumeRunResult> CreateOrResumeRunAsync(List<ChatMessage> messages, CancellationToken cancellationToken = default)
|
||||
private async ValueTask<StreamingRun> CreateOrResumeRunAsync(List<ChatMessage> messages, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// The workflow is validated to be a ChatProtocol workflow by the WorkflowHostAgent before creating the session,
|
||||
// and does not need to be checked again here.
|
||||
@@ -167,258 +154,110 @@ internal sealed class WorkflowSession : AgentSession
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Process messages: convert response content to ExternalResponse, send regular messages as-is
|
||||
ResumeDispatchInfo dispatchInfo = await this.SendMessagesWithResponseConversionAsync(run, messages).ConfigureAwait(false);
|
||||
return new ResumeRunResult(run, dispatchInfo);
|
||||
await run.TrySendMessageAsync(messages).ConfigureAwait(false);
|
||||
return run;
|
||||
}
|
||||
|
||||
StreamingRun newRun = await this._executionEnvironment
|
||||
return await this._executionEnvironment
|
||||
.RunStreamingAsync(this._workflow,
|
||||
messages,
|
||||
this.SessionId,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return new ResumeRunResult(newRun);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends messages to the run, converting FunctionResultContent and UserInputResponseContent
|
||||
/// to ExternalResponse when there's a matching pending request.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// Structured information about how resume content was dispatched.
|
||||
/// </returns>
|
||||
private async ValueTask<ResumeDispatchInfo> SendMessagesWithResponseConversionAsync(StreamingRun run, List<ChatMessage> messages)
|
||||
{
|
||||
List<ChatMessage> regularMessages = [];
|
||||
// Responses are deferred until after regular messages are queued so response handlers
|
||||
// can merge buffered regular content in the same continuation turn.
|
||||
List<(ExternalResponse Response, string RequestId)> externalResponses = [];
|
||||
bool hasMatchedResponseForStartExecutor = false;
|
||||
|
||||
// Tracks content IDs already matched to pending requests within this invocation,
|
||||
// preventing duplicate responses for the same ID from being sent to the workflow engine.
|
||||
HashSet<string>? matchedContentIds = null;
|
||||
|
||||
foreach (ChatMessage message in messages)
|
||||
{
|
||||
List<AIContent> regularContents = [];
|
||||
|
||||
foreach (AIContent content in message.Contents)
|
||||
{
|
||||
string? contentId = GetResponseContentId(content);
|
||||
|
||||
// Skip duplicate response content for an already-matched content ID
|
||||
if (contentId != null && matchedContentIds?.Contains(contentId) == true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (contentId != null
|
||||
&& this.TryGetPendingRequest(contentId) is ExternalRequest pendingRequest)
|
||||
{
|
||||
// For intercepted/complex topologies the port may not be registered in the EdgeMap.
|
||||
// Treat unknown port as non-start-executor (conservative): TurnToken will still be sent.
|
||||
if (run.TryGetResponsePortExecutorId(pendingRequest.PortInfo.PortId, out string? responseExecutorId))
|
||||
{
|
||||
hasMatchedResponseForStartExecutor |= string.Equals(responseExecutorId, this._workflow.StartExecutorId, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
AIContent normalizedResponseContent = NormalizeResponseContentForDelivery(content, pendingRequest);
|
||||
externalResponses.Add((pendingRequest.CreateResponse(normalizedResponseContent), pendingRequest.RequestId));
|
||||
(matchedContentIds ??= new(StringComparer.Ordinal)).Add(contentId);
|
||||
}
|
||||
else
|
||||
{
|
||||
regularContents.Add(content);
|
||||
}
|
||||
}
|
||||
|
||||
if (regularContents.Count > 0)
|
||||
{
|
||||
ChatMessage cloned = message.Clone();
|
||||
cloned.Contents = regularContents;
|
||||
regularMessages.Add(cloned);
|
||||
}
|
||||
}
|
||||
|
||||
// Send regular messages first so response handlers can merge them with responses.
|
||||
bool hasRegularMessages = regularMessages.Count > 0;
|
||||
if (hasRegularMessages)
|
||||
{
|
||||
await run.TrySendMessageAsync(regularMessages).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Send external responses after regular messages.
|
||||
bool hasMatchedExternalResponses = false;
|
||||
foreach ((ExternalResponse response, string requestId) in externalResponses)
|
||||
{
|
||||
await run.SendResponseAsync(response).ConfigureAwait(false);
|
||||
hasMatchedExternalResponses = true;
|
||||
this.RemovePendingRequest(requestId);
|
||||
}
|
||||
|
||||
return new ResumeDispatchInfo(
|
||||
hasRegularMessages,
|
||||
hasMatchedExternalResponses,
|
||||
hasMatchedResponseForStartExecutor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the workflow-facing request content surfaced in response updates.
|
||||
/// </summary>
|
||||
private static AIContent CreateRequestContentForDelivery(ExternalRequest request) => request switch
|
||||
{
|
||||
ExternalRequest externalRequest when externalRequest.TryGetDataAs(out FunctionCallContent? functionCallContent)
|
||||
=> CloneFunctionCallContent(functionCallContent, externalRequest.RequestId),
|
||||
ExternalRequest externalRequest when externalRequest.TryGetDataAs(out ToolApprovalRequestContent? toolApprovalRequestContent)
|
||||
=> CloneToolApprovalRequestContent(toolApprovalRequestContent, externalRequest.RequestId),
|
||||
ExternalRequest externalRequest
|
||||
=> externalRequest.ToFunctionCall(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites workflow-facing response content back to the original agent-owned content ID.
|
||||
/// </summary>
|
||||
private static AIContent NormalizeResponseContentForDelivery(AIContent content, ExternalRequest request) => content switch
|
||||
{
|
||||
FunctionResultContent functionResultContent when request.TryGetDataAs(out FunctionCallContent? functionCallContent)
|
||||
=> CloneFunctionResultContent(functionResultContent, functionCallContent.CallId),
|
||||
ToolApprovalResponseContent toolApprovalResponseContent when request.TryGetDataAs(out ToolApprovalRequestContent? toolApprovalRequestContent)
|
||||
=> CloneToolApprovalResponseContent(toolApprovalResponseContent, toolApprovalRequestContent.RequestId),
|
||||
_ => content,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the workflow-facing request ID from response content types.
|
||||
/// </summary>
|
||||
private static string? GetResponseContentId(AIContent content) => content switch
|
||||
{
|
||||
FunctionResultContent functionResultContent => functionResultContent.CallId,
|
||||
ToolApprovalResponseContent toolApprovalResponseContent => toolApprovalResponseContent.RequestId,
|
||||
_ => null
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Tries to get a pending request by workflow-facing request ID.
|
||||
/// </summary>
|
||||
private ExternalRequest? TryGetPendingRequest(string requestId) =>
|
||||
this._pendingRequests.TryGetValue(requestId, out ExternalRequest? request) ? request : null;
|
||||
|
||||
/// <summary>
|
||||
/// Adds a pending request indexed by workflow-facing request ID.
|
||||
/// </summary>
|
||||
private void AddPendingRequest(string requestId, ExternalRequest request) => this._pendingRequests[requestId] = request;
|
||||
|
||||
/// <summary>
|
||||
/// Removes a pending request by workflow-facing request ID.
|
||||
/// </summary>
|
||||
private void RemovePendingRequest(string requestId) =>
|
||||
this._pendingRequests.Remove(requestId);
|
||||
|
||||
internal async
|
||||
IAsyncEnumerable<AgentResponseUpdate> InvokeStageAsync(
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.LastResponseId = Guid.NewGuid().ToString("N");
|
||||
List<ChatMessage> messages = this.ChatHistoryProvider.GetFromBookmark(this).ToList();
|
||||
try
|
||||
{
|
||||
this.LastResponseId = Guid.NewGuid().ToString("N");
|
||||
List<ChatMessage> messages = this.ChatHistoryProvider.GetFromBookmark(this).ToList();
|
||||
|
||||
ResumeRunResult resumeResult =
|
||||
await this.CreateOrResumeRunAsync(messages, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
#pragma warning disable CA2007 // Analyzer misfiring.
|
||||
await using StreamingRun run = resumeResult.Run;
|
||||
#pragma warning disable CA2007 // Analyzer misfiring and not seeing .ConfigureAwait(false) below.
|
||||
await using StreamingRun run =
|
||||
await this.CreateOrResumeRunAsync(messages, cancellationToken).ConfigureAwait(false);
|
||||
#pragma warning restore CA2007
|
||||
|
||||
ResumeDispatchInfo dispatchInfo = resumeResult.DispatchInfo;
|
||||
|
||||
// Send a TurnToken to the start executor unless the only activity is an external
|
||||
// response directed at the start executor itself (which self-emits a TurnToken via
|
||||
// ContinueTurnAsync). Non-start executors (e.g., RequestInfoExecutor) do not emit
|
||||
// TurnTokens after processing responses, so the session must always provide one.
|
||||
bool shouldSendTurnToken =
|
||||
!dispatchInfo.HasMatchedExternalResponses
|
||||
|| !dispatchInfo.HasMatchedResponseForStartExecutor;
|
||||
if (shouldSendTurnToken)
|
||||
{
|
||||
await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
|
||||
}
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken)
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
.WithCancellation(cancellationToken))
|
||||
{
|
||||
switch (evt)
|
||||
{
|
||||
case AgentResponseUpdateEvent agentUpdate:
|
||||
yield return agentUpdate.Update;
|
||||
break;
|
||||
switch (evt)
|
||||
{
|
||||
case AgentResponseUpdateEvent agentUpdate:
|
||||
yield return agentUpdate.Update;
|
||||
break;
|
||||
|
||||
case RequestInfoEvent requestInfo:
|
||||
AIContent requestContent = CreateRequestContentForDelivery(requestInfo.Request);
|
||||
case RequestInfoEvent requestInfo:
|
||||
FunctionCallContent fcContent = requestInfo.Request.ToFunctionCall();
|
||||
AgentResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, fcContent);
|
||||
yield return update;
|
||||
break;
|
||||
|
||||
// Track the pending request so we can convert incoming responses back to ExternalResponse.
|
||||
// External callers respond using the workflow-facing request ID, which is always RequestId.
|
||||
this.AddPendingRequest(requestInfo.Request.RequestId, requestInfo.Request);
|
||||
case WorkflowErrorEvent workflowError:
|
||||
Exception? exception = workflowError.Exception;
|
||||
if (exception is TargetInvocationException tie && tie.InnerException != null)
|
||||
{
|
||||
exception = tie.InnerException;
|
||||
}
|
||||
|
||||
AgentResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, requestContent);
|
||||
yield return update;
|
||||
break;
|
||||
if (exception != null)
|
||||
{
|
||||
string message = this._includeExceptionDetails
|
||||
? exception.Message
|
||||
: "An error occurred while executing the workflow.";
|
||||
|
||||
case WorkflowErrorEvent workflowError:
|
||||
Exception? exception = workflowError.Exception;
|
||||
if (exception is TargetInvocationException tie && tie.InnerException != null)
|
||||
{
|
||||
exception = tie.InnerException;
|
||||
}
|
||||
ErrorContent errorContent = new(message);
|
||||
yield return this.CreateUpdate(this.LastResponseId, evt, errorContent);
|
||||
}
|
||||
|
||||
if (exception != null)
|
||||
{
|
||||
string message = this._includeExceptionDetails
|
||||
? exception.Message
|
||||
: "An error occurred while executing the workflow.";
|
||||
break;
|
||||
|
||||
ErrorContent errorContent = new(message);
|
||||
yield return this.CreateUpdate(this.LastResponseId, evt, errorContent);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case SuperStepCompletedEvent stepCompleted:
|
||||
this.LastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint;
|
||||
goto default;
|
||||
|
||||
case WorkflowOutputEvent output:
|
||||
IEnumerable<ChatMessage>? updateMessages = output.Data switch
|
||||
{
|
||||
IEnumerable<ChatMessage> chatMessages => chatMessages,
|
||||
ChatMessage chatMessage => [chatMessage],
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (!this._includeWorkflowOutputsInResponse || updateMessages == null)
|
||||
{
|
||||
case SuperStepCompletedEvent stepCompleted:
|
||||
this.LastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint;
|
||||
goto default;
|
||||
}
|
||||
|
||||
foreach (ChatMessage message in updateMessages)
|
||||
{
|
||||
yield return this.CreateUpdate(this.LastResponseId, evt, message);
|
||||
}
|
||||
break;
|
||||
case WorkflowOutputEvent output:
|
||||
IEnumerable<ChatMessage>? updateMessages = output.Data switch
|
||||
{
|
||||
IEnumerable<ChatMessage> chatMessages => chatMessages,
|
||||
ChatMessage chatMessage => [chatMessage],
|
||||
_ => null
|
||||
};
|
||||
|
||||
default:
|
||||
// Emit all other workflow events for observability (DevUI, logging, etc.)
|
||||
yield return new AgentResponseUpdate(ChatRole.Assistant, [])
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
Role = ChatRole.Assistant,
|
||||
ResponseId = this.LastResponseId,
|
||||
RawRepresentation = evt
|
||||
};
|
||||
break;
|
||||
if (!this._includeWorkflowOutputsInResponse || updateMessages == null)
|
||||
{
|
||||
goto default;
|
||||
}
|
||||
|
||||
foreach (ChatMessage message in updateMessages)
|
||||
{
|
||||
yield return this.CreateUpdate(this.LastResponseId, evt, message);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// Emit all other workflow events for observability (DevUI, logging, etc.)
|
||||
yield return new AgentResponseUpdate(ChatRole.Assistant, [])
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
Role = ChatRole.Assistant,
|
||||
ResponseId = this.LastResponseId,
|
||||
RawRepresentation = evt
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Do we want to try to undo the step, and not update the bookmark?
|
||||
this.ChatHistoryProvider.UpdateBookmark(this);
|
||||
}
|
||||
}
|
||||
|
||||
public string? LastResponseId { get; set; }
|
||||
@@ -428,116 +267,15 @@ internal sealed class WorkflowSession : AgentSession
|
||||
/// <inheritdoc/>
|
||||
public WorkflowChatHistoryProvider ChatHistoryProvider { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Captures the outcome of creating or resuming a workflow run,
|
||||
/// indicating what types of messages were sent during resume.
|
||||
/// </summary>
|
||||
private readonly struct ResumeRunResult
|
||||
{
|
||||
/// <summary>The streaming run that was created or resumed.</summary>
|
||||
public StreamingRun Run { get; }
|
||||
|
||||
/// <summary>How resume-time content was dispatched into the workflow runtime.</summary>
|
||||
public ResumeDispatchInfo DispatchInfo { get; }
|
||||
|
||||
public ResumeRunResult(StreamingRun run, ResumeDispatchInfo dispatchInfo = default)
|
||||
{
|
||||
this.Run = Throw.IfNull(run);
|
||||
this.DispatchInfo = dispatchInfo;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures how resumed input was split across regular-message and external-response delivery paths.
|
||||
/// </summary>
|
||||
private readonly struct ResumeDispatchInfo
|
||||
{
|
||||
public ResumeDispatchInfo(bool hasRegularMessages, bool hasMatchedExternalResponses, bool hasMatchedResponseForStartExecutor)
|
||||
{
|
||||
this.HasRegularMessages = hasRegularMessages;
|
||||
this.HasMatchedExternalResponses = hasMatchedExternalResponses;
|
||||
this.HasMatchedResponseForStartExecutor = hasMatchedResponseForStartExecutor;
|
||||
}
|
||||
|
||||
public bool HasRegularMessages { get; }
|
||||
|
||||
public bool HasMatchedExternalResponses { get; }
|
||||
|
||||
public bool HasMatchedResponseForStartExecutor { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clones a <see cref="FunctionCallContent"/> with a workflow-facing call ID.
|
||||
/// </summary>
|
||||
private static FunctionCallContent CloneFunctionCallContent(FunctionCallContent content, string callId)
|
||||
{
|
||||
FunctionCallContent clone = new(callId, content.Name, content.Arguments)
|
||||
{
|
||||
Exception = content.Exception,
|
||||
InformationalOnly = content.InformationalOnly,
|
||||
};
|
||||
|
||||
return CopyContentMetadata(content, clone);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clones a <see cref="FunctionResultContent"/> with an agent-owned call ID.
|
||||
/// </summary>
|
||||
private static FunctionResultContent CloneFunctionResultContent(FunctionResultContent content, string callId)
|
||||
{
|
||||
FunctionResultContent clone = new(callId, content.Result)
|
||||
{
|
||||
Exception = content.Exception,
|
||||
};
|
||||
|
||||
return CopyContentMetadata(content, clone);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clones a <see cref="ToolApprovalRequestContent"/> with a workflow-facing request ID.
|
||||
/// </summary>
|
||||
private static ToolApprovalRequestContent CloneToolApprovalRequestContent(ToolApprovalRequestContent content, string id)
|
||||
{
|
||||
ToolApprovalRequestContent clone = new(id, content.ToolCall);
|
||||
return CopyContentMetadata(content, clone);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clones a <see cref="ToolApprovalResponseContent"/> with an agent-owned request ID.
|
||||
/// </summary>
|
||||
private static ToolApprovalResponseContent CloneToolApprovalResponseContent(ToolApprovalResponseContent content, string id)
|
||||
{
|
||||
ToolApprovalResponseContent clone = new(id, content.Approved, content.ToolCall)
|
||||
{
|
||||
Reason = content.Reason,
|
||||
};
|
||||
|
||||
return CopyContentMetadata(content, clone);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies shared <see cref="AIContent"/> metadata to a cloned content instance.
|
||||
/// </summary>
|
||||
private static TContent CopyContentMetadata<TContent>(AIContent source, TContent target)
|
||||
where TContent : AIContent
|
||||
{
|
||||
target.AdditionalProperties = source.AdditionalProperties;
|
||||
target.Annotations = source.Annotations;
|
||||
target.RawRepresentation = source.RawRepresentation;
|
||||
return target;
|
||||
}
|
||||
|
||||
internal sealed class SessionState(
|
||||
string sessionId,
|
||||
CheckpointInfo? lastCheckpoint,
|
||||
InMemoryCheckpointManager? checkpointManager = null,
|
||||
AgentSessionStateBag? stateBag = null,
|
||||
Dictionary<string, ExternalRequest>? pendingRequests = null)
|
||||
AgentSessionStateBag? stateBag = null)
|
||||
{
|
||||
public string SessionId { get; } = sessionId;
|
||||
public CheckpointInfo? LastCheckpoint { get; } = lastCheckpoint;
|
||||
public InMemoryCheckpointManager? CheckpointManager { get; } = checkpointManager;
|
||||
public AgentSessionStateBag StateBag { get; } = stateBag ?? new();
|
||||
public Dictionary<string, ExternalRequest>? PendingRequests { get; } = pendingRequests;
|
||||
}
|
||||
}
|
||||
|
||||
-2
@@ -161,8 +161,6 @@ internal sealed class AIContextProviderChatClient : DelegatingChatClient
|
||||
}
|
||||
|
||||
// Materialize the accumulated context back into messages and options.
|
||||
// Clone options to avoid mutating the caller's instance across calls.
|
||||
options = options?.Clone();
|
||||
var enrichedMessages = aiContext.Messages ?? [];
|
||||
|
||||
var tools = aiContext.Tools as IList<AITool> ?? aiContext.Tools?.ToList();
|
||||
|
||||
-161
@@ -250,129 +250,6 @@ public class AIContextProviderChatClientTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region Shared Options Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_SharedOptions_ProviderToolsDoNotAccumulateAcrossCallsAsync()
|
||||
{
|
||||
// Arrange: track tool count seen by the inner client on each call
|
||||
var toolCountsSeenByInner = new List<int>();
|
||||
|
||||
var innerClient = CreateMockChatClient(
|
||||
onGetResponse: (_, options, _) =>
|
||||
{
|
||||
toolCountsSeenByInner.Add(options?.Tools?.Count ?? 0);
|
||||
return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")]));
|
||||
});
|
||||
|
||||
var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]);
|
||||
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
|
||||
|
||||
var sharedOptions = new ChatOptions
|
||||
{
|
||||
Tools = new List<AITool> { new TestAITool() }
|
||||
};
|
||||
|
||||
// Act: make 3 calls reusing the same ChatOptions
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
await RunWithAgentContextAsync(chatClient, sharedOptions);
|
||||
}
|
||||
|
||||
// Assert: each call should see exactly 2 tools (1 baseline + 1 injected)
|
||||
Assert.Equal(3, toolCountsSeenByInner.Count);
|
||||
Assert.All(toolCountsSeenByInner, count => Assert.Equal(2, count));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetResponseAsync_SharedOptions_OriginalToolsNotMutatedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var innerClient = CreateMockChatClient(
|
||||
onGetResponse: (_, _, _) =>
|
||||
Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")])));
|
||||
|
||||
var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]);
|
||||
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
|
||||
|
||||
var baselineTool = new TestAITool();
|
||||
var originalTools = new List<AITool> { baselineTool };
|
||||
var sharedOptions = new ChatOptions
|
||||
{
|
||||
Tools = originalTools
|
||||
};
|
||||
|
||||
// Act
|
||||
await RunWithAgentContextAsync(chatClient, sharedOptions);
|
||||
|
||||
// Assert: the original list should still contain only the baseline tool
|
||||
Assert.Single(originalTools);
|
||||
Assert.Same(baselineTool, originalTools[0]);
|
||||
Assert.Same(originalTools, sharedOptions.Tools);
|
||||
Assert.Same(baselineTool, originalTools[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStreamingResponseAsync_SharedOptions_ProviderToolsDoNotAccumulateAcrossCallsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var toolCountsSeenByInner = new List<int>();
|
||||
|
||||
var innerClient = CreateMockStreamingChatClient(
|
||||
onGetStreamingResponse: (_, options, _) =>
|
||||
{
|
||||
toolCountsSeenByInner.Add(options?.Tools?.Count ?? 0);
|
||||
return ToAsyncEnumerableAsync(
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "Response"));
|
||||
});
|
||||
|
||||
var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]);
|
||||
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
|
||||
|
||||
var sharedOptions = new ChatOptions
|
||||
{
|
||||
Tools = new List<AITool> { new TestAITool() }
|
||||
};
|
||||
|
||||
// Act: make 3 streaming calls reusing the same ChatOptions
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
await RunStreamingWithAgentContextAsync(chatClient, [], sharedOptions);
|
||||
}
|
||||
|
||||
// Assert: each call should see exactly 2 tools (1 baseline + 1 injected)
|
||||
Assert.Equal(3, toolCountsSeenByInner.Count);
|
||||
Assert.All(toolCountsSeenByInner, count => Assert.Equal(2, count));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStreamingResponseAsync_SharedOptions_OriginalToolsNotMutatedAsync()
|
||||
{
|
||||
// Arrange
|
||||
var innerClient = CreateMockStreamingChatClient(
|
||||
onGetStreamingResponse: (_, _, _) => ToAsyncEnumerableAsync(
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "Response")));
|
||||
|
||||
var provider = new TestAIContextProvider("key1", provideTools: [new TestAITool()]);
|
||||
var chatClient = new AIContextProviderChatClient(innerClient, [provider]);
|
||||
|
||||
var baselineTool = new TestAITool();
|
||||
var originalTools = new List<AITool> { baselineTool };
|
||||
var sharedOptions = new ChatOptions
|
||||
{
|
||||
Tools = originalTools
|
||||
};
|
||||
|
||||
// Act
|
||||
await RunStreamingWithAgentContextAsync(chatClient, [], sharedOptions);
|
||||
|
||||
// Assert: the original list should still contain only the baseline tool
|
||||
Assert.Single(originalTools);
|
||||
Assert.Same(baselineTool, originalTools[0]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Builder Extension Tests
|
||||
|
||||
[Fact]
|
||||
@@ -464,44 +341,6 @@ public class AIContextProviderChatClientTests
|
||||
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a chat client within an agent context with the specified options.
|
||||
/// </summary>
|
||||
private static async Task RunWithAgentContextAsync(AIContextProviderChatClient chatClient, ChatOptions options)
|
||||
{
|
||||
var agent = new TestAIAgent
|
||||
{
|
||||
RunAsyncFunc = async (messages, session, agentOptions, ct) =>
|
||||
{
|
||||
var response = await chatClient.GetResponseAsync(messages, options, ct);
|
||||
return new AgentResponse(response);
|
||||
}
|
||||
};
|
||||
|
||||
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a streaming chat client within an agent context with the specified options.
|
||||
/// </summary>
|
||||
private static async Task RunStreamingWithAgentContextAsync(AIContextProviderChatClient chatClient, List<ChatResponseUpdate> updates, ChatOptions options)
|
||||
{
|
||||
var agent = new TestAIAgent
|
||||
{
|
||||
RunAsyncFunc = async (messages, session, agentOptions, ct) =>
|
||||
{
|
||||
await foreach (var update in chatClient.GetStreamingResponseAsync(messages, options, ct))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "done")]);
|
||||
}
|
||||
};
|
||||
|
||||
await agent.RunAsync([new ChatMessage(ChatRole.User, "Hello")], s_mockSession);
|
||||
}
|
||||
|
||||
private static IChatClient CreateMockChatClient(
|
||||
Func<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken, Task<ChatResponse>> onGetResponse)
|
||||
{
|
||||
|
||||
@@ -10,8 +10,20 @@ using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
public class AIAgentHostExecutorTests : AIAgentHostingExecutorTestsBase
|
||||
public class AIAgentHostExecutorTests
|
||||
{
|
||||
private const string TestAgentId = nameof(TestAgentId);
|
||||
private const string TestAgentName = nameof(TestAgentName);
|
||||
|
||||
private static readonly string[] s_messageStrings = [
|
||||
"",
|
||||
"Hello world!",
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
|
||||
"Quisque dignissim ante odio, at facilisis orci porta a. Duis mi augue, fringilla eu egestas a, pellentesque sed lacus."
|
||||
];
|
||||
|
||||
private static List<ChatMessage> TestMessages => TestReplayAgent.ToChatMessages(s_messageStrings);
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null)]
|
||||
[InlineData(null, true)]
|
||||
@@ -38,7 +50,30 @@ public class AIAgentHostExecutorTests : AIAgentHostingExecutorTestsBase
|
||||
bool expectingEvents = turnSetting ?? executorSetting ?? false;
|
||||
|
||||
AgentResponseUpdateEvent[] updates = testContext.Events.OfType<AgentResponseUpdateEvent>().ToArray();
|
||||
CheckResponseUpdateEventsAgainstTestMessages(updates, expectingEvents, agent.GetDescriptiveId());
|
||||
if (expectingEvents)
|
||||
{
|
||||
// The way TestReplayAgent is set up, it will emit one update per non-empty AIContent
|
||||
List<AIContent> expectedUpdateContents = TestMessages.SelectMany(message => message.Contents).ToList();
|
||||
|
||||
updates.Should().HaveCount(expectedUpdateContents.Count);
|
||||
for (int i = 0; i < updates.Length; i++)
|
||||
{
|
||||
AgentResponseUpdateEvent updateEvent = updates[i];
|
||||
AIContent expectedUpdateContent = expectedUpdateContents[i];
|
||||
|
||||
updateEvent.ExecutorId.Should().Be(agent.GetDescriptiveId());
|
||||
|
||||
AgentResponseUpdate update = updateEvent.Update;
|
||||
update.AuthorName.Should().Be(TestAgentName);
|
||||
update.AgentId.Should().Be(TestAgentId);
|
||||
update.Contents.Should().HaveCount(1);
|
||||
update.Contents[0].Should().BeEquivalentTo(expectedUpdateContent);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
updates.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -57,7 +92,30 @@ public class AIAgentHostExecutorTests : AIAgentHostingExecutorTestsBase
|
||||
|
||||
// Assert
|
||||
AgentResponseEvent[] updates = testContext.Events.OfType<AgentResponseEvent>().ToArray();
|
||||
CheckResponseEventsAgainstTestMessages(updates, expectingResponse: executorSetting, agent.GetDescriptiveId());
|
||||
if (executorSetting)
|
||||
{
|
||||
updates.Should().HaveCount(1);
|
||||
|
||||
AgentResponseEvent responseEvent = updates[0];
|
||||
responseEvent.ExecutorId.Should().Be(agent.GetDescriptiveId());
|
||||
|
||||
AgentResponse response = responseEvent.Response;
|
||||
response.AgentId.Should().Be(TestAgentId);
|
||||
response.Messages.Should().HaveCount(TestMessages.Count - 1);
|
||||
|
||||
for (int i = 0; i < response.Messages.Count; i++)
|
||||
{
|
||||
ChatMessage responseMessage = response.Messages[i];
|
||||
ChatMessage expectedMessage = TestMessages[i + 1]; // Skip the first empty message
|
||||
|
||||
responseMessage.AuthorName.Should().Be(TestAgentName);
|
||||
responseMessage.Text.Should().Be(expectedMessage.Text);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
updates.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
private static ChatMessage UserMessage => new(ChatRole.User, "Hello from User!") { AuthorName = "User" };
|
||||
|
||||
-79
@@ -1,79 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
public abstract class AIAgentHostingExecutorTestsBase
|
||||
{
|
||||
protected const string TestAgentId = nameof(TestAgentId);
|
||||
protected const string TestAgentName = nameof(TestAgentName);
|
||||
|
||||
private static readonly string[] s_messageStrings = [
|
||||
"",
|
||||
"Hello world!",
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
|
||||
"Quisque dignissim ante odio, at facilisis orci porta a. Duis mi augue, fringilla eu egestas a, pellentesque sed lacus."
|
||||
];
|
||||
|
||||
protected static List<ChatMessage> TestMessages => TestReplayAgent.ToChatMessages(s_messageStrings);
|
||||
|
||||
protected static void CheckResponseUpdateEventsAgainstTestMessages(AgentResponseUpdateEvent[] updates, bool expectingEvents, string expectedExecutorId)
|
||||
{
|
||||
if (expectingEvents)
|
||||
{
|
||||
// The way TestReplayAgent is set up, it will emit one update per non-empty AIContent
|
||||
List<AIContent> expectedUpdateContents = TestMessages.SelectMany(message => message.Contents).ToList();
|
||||
|
||||
updates.Should().HaveCount(expectedUpdateContents.Count);
|
||||
for (int i = 0; i < updates.Length; i++)
|
||||
{
|
||||
AgentResponseUpdateEvent updateEvent = updates[i];
|
||||
AIContent expectedUpdateContent = expectedUpdateContents[i];
|
||||
|
||||
updateEvent.ExecutorId.Should().Be(expectedExecutorId);
|
||||
|
||||
AgentResponseUpdate update = updateEvent.Update;
|
||||
update.AuthorName.Should().Be(TestAgentName);
|
||||
update.AgentId.Should().Be(TestAgentId);
|
||||
update.Contents.Should().HaveCount(1);
|
||||
update.Contents[0].Should().BeEquivalentTo(expectedUpdateContent);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
updates.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
protected static void CheckResponseEventsAgainstTestMessages(AgentResponseEvent[] updates, bool expectingResponse, string expectedExecutorId)
|
||||
{
|
||||
if (expectingResponse)
|
||||
{
|
||||
updates.Should().HaveCount(1);
|
||||
|
||||
AgentResponseEvent responseEvent = updates[0];
|
||||
responseEvent.ExecutorId.Should().Be(expectedExecutorId);
|
||||
|
||||
AgentResponse response = responseEvent.Response;
|
||||
response.AgentId.Should().Be(TestAgentId);
|
||||
response.Messages.Should().HaveCount(TestMessages.Count - 1);
|
||||
|
||||
for (int i = 0; i < response.Messages.Count; i++)
|
||||
{
|
||||
ChatMessage responseMessage = response.Messages[i];
|
||||
ChatMessage expectedMessage = TestMessages[i + 1]; // Skip the first empty message
|
||||
|
||||
responseMessage.AuthorName.Should().Be(TestAgentName);
|
||||
responseMessage.Text.Should().Be(expectedMessage.Text);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
updates.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Specialized;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
public class HandoffAgentExecutorTests : AIAgentHostingExecutorTestsBase
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null, null)]
|
||||
[InlineData(null, true)]
|
||||
[InlineData(null, false)]
|
||||
[InlineData(true, null)]
|
||||
[InlineData(true, true)]
|
||||
[InlineData(true, false)]
|
||||
[InlineData(false, null)]
|
||||
[InlineData(false, true)]
|
||||
[InlineData(false, false)]
|
||||
public async Task Test_HandoffAgentExecutor_EmitsStreamingUpdatesIFFConfiguredAsync(bool? executorSetting, bool? turnSetting)
|
||||
{
|
||||
// Arrange
|
||||
TestRunContext testContext = new();
|
||||
TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName);
|
||||
|
||||
HandoffAgentExecutorOptions options = new("",
|
||||
emitAgentResponseEvents: false,
|
||||
emitAgentResponseUpdateEvents: executorSetting,
|
||||
HandoffToolCallFilteringBehavior.None);
|
||||
|
||||
HandoffAgentExecutor executor = new(agent, options);
|
||||
testContext.ConfigureExecutor(executor);
|
||||
|
||||
// Act
|
||||
HandoffState message = new(new(turnSetting), null, []);
|
||||
await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id));
|
||||
|
||||
// Assert
|
||||
bool expectingStreamingUpdates = turnSetting ?? executorSetting ?? false;
|
||||
|
||||
AgentResponseUpdateEvent[] updates = testContext.Events.OfType<AgentResponseUpdateEvent>().ToArray();
|
||||
CheckResponseUpdateEventsAgainstTestMessages(updates, expectingStreamingUpdates, agent.GetDescriptiveId());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task Test_HandoffAgentExecutor_EmitsResponseIFFConfiguredAsync(bool executorSetting)
|
||||
{
|
||||
// Arrange
|
||||
TestRunContext testContext = new();
|
||||
TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName);
|
||||
|
||||
HandoffAgentExecutorOptions options = new("",
|
||||
emitAgentResponseEvents: executorSetting,
|
||||
emitAgentResponseUpdateEvents: false,
|
||||
HandoffToolCallFilteringBehavior.None);
|
||||
|
||||
HandoffAgentExecutor executor = new(agent, options);
|
||||
testContext.ConfigureExecutor(executor);
|
||||
|
||||
// Act
|
||||
HandoffState message = new(new(false), null, []);
|
||||
await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id));
|
||||
|
||||
// Assert
|
||||
AgentResponseEvent[] updates = testContext.Events.OfType<AgentResponseEvent>().ToArray();
|
||||
CheckResponseEventsAgainstTestMessages(updates, expectingResponse: executorSetting, agent.GetDescriptiveId());
|
||||
}
|
||||
}
|
||||
@@ -673,55 +673,6 @@ public class JsonSerializationTests
|
||||
ValidateCheckpoint(retrievedCheckpoint, prototype);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_SessionState_JsonRoundtrip_WithPendingRequests()
|
||||
{
|
||||
// Arrange
|
||||
Dictionary<string, ExternalRequest> pendingRequests = new()
|
||||
{
|
||||
["call-1"] = TestExternalRequest,
|
||||
["call-2"] = ExternalRequest.Create(TestPort, "Request2", "OtherData"),
|
||||
};
|
||||
|
||||
WorkflowSession.SessionState prototype = new(
|
||||
sessionId: "test-session-123",
|
||||
lastCheckpoint: TestParentCheckpointInfo,
|
||||
pendingRequests: pendingRequests);
|
||||
|
||||
// Act
|
||||
WorkflowSession.SessionState result = RunJsonRoundtrip(prototype);
|
||||
|
||||
// Assert
|
||||
result.SessionId.Should().Be(prototype.SessionId);
|
||||
result.LastCheckpoint.Should().Be(prototype.LastCheckpoint);
|
||||
result.StateBag.Should().NotBeNull();
|
||||
result.PendingRequests.Should().NotBeNull()
|
||||
.And.HaveCount(pendingRequests.Count);
|
||||
|
||||
foreach (string key in pendingRequests.Keys)
|
||||
{
|
||||
result.PendingRequests.Should().ContainKey(key);
|
||||
ValidateExternalRequest(result.PendingRequests![key], pendingRequests[key]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_SessionState_JsonRoundtrip_WithoutPendingRequests()
|
||||
{
|
||||
// Arrange
|
||||
WorkflowSession.SessionState prototype = new(
|
||||
sessionId: "test-session-456",
|
||||
lastCheckpoint: null);
|
||||
|
||||
// Act
|
||||
WorkflowSession.SessionState result = RunJsonRoundtrip(prototype);
|
||||
|
||||
// Assert
|
||||
result.SessionId.Should().Be(prototype.SessionId);
|
||||
result.LastCheckpoint.Should().BeNull();
|
||||
result.PendingRequests.Should().BeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the default behavior (without AllowOutOfOrderMetadataProperties) fails
|
||||
/// when $type metadata is not the first property, demonstrating the PostgreSQL jsonb issue.
|
||||
|
||||
@@ -28,185 +28,7 @@ public sealed class ExpectedException : Exception
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A simple agent that emits a FunctionCallContent or ToolApprovalRequestContent request.
|
||||
/// Used to test that RequestInfoEvent handling preserves the original content type.
|
||||
/// </summary>
|
||||
internal sealed class RequestEmittingAgent : AIAgent
|
||||
{
|
||||
private readonly AIContent _requestContent;
|
||||
private readonly bool _completeOnResponse;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="RequestEmittingAgent"/> that emits the given request content.
|
||||
/// </summary>
|
||||
/// <param name="requestContent">The content to emit on each turn.</param>
|
||||
/// <param name="completeOnResponse">
|
||||
/// When <see langword="true"/>, the agent emits a text completion instead of re-emitting
|
||||
/// the request when the incoming messages contain a <see cref="FunctionResultContent"/>
|
||||
/// or <see cref="ToolApprovalResponseContent"/>. This models realistic agent behaviour
|
||||
/// where the agent processes the tool result and produces a final answer.
|
||||
/// </param>
|
||||
public RequestEmittingAgent(AIContent requestContent, bool completeOnResponse = false)
|
||||
{
|
||||
this._requestContent = requestContent;
|
||||
this._completeOnResponse = completeOnResponse;
|
||||
}
|
||||
|
||||
private sealed class Session : AgentSession
|
||||
{
|
||||
public Session() { }
|
||||
}
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> new(new Session());
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
=> new(new Session());
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> default;
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> this.RunStreamingAsync(messages, session, options, cancellationToken).ToAgentResponseAsync(cancellationToken);
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (this._completeOnResponse && messages.Any(m => m.Contents.Any(c =>
|
||||
c is FunctionResultContent || c is ToolApprovalResponseContent)))
|
||||
{
|
||||
yield return new AgentResponseUpdate(ChatRole.Assistant, [new TextContent("Request processed")]);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Emit the request content
|
||||
yield return new AgentResponseUpdate(ChatRole.Assistant, [this._requestContent]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class KickoffOnStartExecutor : ChatProtocolExecutor
|
||||
{
|
||||
private static readonly ChatProtocolExecutorOptions s_options = new()
|
||||
{
|
||||
AutoSendTurnToken = false,
|
||||
};
|
||||
|
||||
private readonly string _downstreamExecutorId;
|
||||
private readonly string _kickoffInputText;
|
||||
private readonly string _kickoffMessageText;
|
||||
private readonly string _regularResumeText;
|
||||
private readonly string _regularProcessedText;
|
||||
|
||||
public KickoffOnStartExecutor(
|
||||
string id,
|
||||
string downstreamExecutorId,
|
||||
string kickoffInputText,
|
||||
string kickoffMessageText,
|
||||
string regularResumeText,
|
||||
string regularProcessedText)
|
||||
: base(id, s_options)
|
||||
{
|
||||
this._downstreamExecutorId = downstreamExecutorId;
|
||||
this._kickoffInputText = kickoffInputText;
|
||||
this._kickoffMessageText = kickoffMessageText;
|
||||
this._regularResumeText = regularResumeText;
|
||||
this._regularProcessedText = regularProcessedText;
|
||||
}
|
||||
|
||||
protected override async ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
|
||||
{
|
||||
List<string> textContents =
|
||||
[
|
||||
.. messages
|
||||
.SelectMany(message => message.Contents.OfType<TextContent>())
|
||||
.Select(content => content.Text)
|
||||
];
|
||||
|
||||
if (textContents.Contains(this._kickoffInputText, StringComparer.Ordinal))
|
||||
{
|
||||
await context.SendMessageAsync(
|
||||
new List<ChatMessage> { new(ChatRole.User, this._kickoffMessageText) },
|
||||
this._downstreamExecutorId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
await context.SendMessageAsync(
|
||||
new TurnToken(emitEvents),
|
||||
this._downstreamExecutorId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (textContents.Contains(this._regularResumeText, StringComparer.Ordinal))
|
||||
{
|
||||
AgentResponseUpdate update = new(ChatRole.Assistant, [new TextContent(this._regularProcessedText)])
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
ResponseId = Guid.NewGuid().ToString("N"),
|
||||
Role = ChatRole.Assistant,
|
||||
};
|
||||
|
||||
await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A start executor that always emits a response update on every turn,
|
||||
/// useful for verifying that a TurnToken was delivered by the session.
|
||||
/// On the first turn (user messages present), it kicks off a downstream executor.
|
||||
/// </summary>
|
||||
internal sealed class TurnTrackingStartExecutor : ChatProtocolExecutor
|
||||
{
|
||||
private static readonly ChatProtocolExecutorOptions s_options = new()
|
||||
{
|
||||
AutoSendTurnToken = false,
|
||||
};
|
||||
|
||||
private readonly string _downstreamExecutorId;
|
||||
private readonly string _activatedMarker;
|
||||
private int _activationCount;
|
||||
|
||||
/// <summary>Gets the number of times this executor has been activated (i.e., <see cref="TakeTurnAsync"/> called).</summary>
|
||||
public int ActivationCount => this._activationCount;
|
||||
|
||||
public TurnTrackingStartExecutor(string id, string downstreamExecutorId, string activatedMarker)
|
||||
: base(id, s_options)
|
||||
{
|
||||
this._downstreamExecutorId = downstreamExecutorId;
|
||||
this._activatedMarker = activatedMarker;
|
||||
}
|
||||
|
||||
protected override async ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Interlocked.Increment(ref this._activationCount);
|
||||
|
||||
// On the first turn, forward user messages and a TurnToken to the downstream executor.
|
||||
if (messages.Any(m => m.Role == ChatRole.User))
|
||||
{
|
||||
await context.SendMessageAsync(
|
||||
messages,
|
||||
this._downstreamExecutorId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
await context.SendMessageAsync(
|
||||
new TurnToken(emitEvents),
|
||||
this._downstreamExecutorId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Always emit a marker to prove this executor was activated.
|
||||
AgentResponseUpdate update = new(ChatRole.Assistant, [new TextContent(this._activatedMarker)])
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
ResponseId = Guid.NewGuid().ToString("N"),
|
||||
Role = ChatRole.Assistant,
|
||||
};
|
||||
|
||||
await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public class WorkflowHostSmokeTests : AIAgentHostingExecutorTestsBase
|
||||
public class WorkflowHostSmokeTests
|
||||
{
|
||||
private sealed class AlwaysFailsAIAgent(bool failByThrowing) : AIAgent
|
||||
{
|
||||
@@ -290,511 +112,4 @@ public class WorkflowHostSmokeTests : AIAgentHostingExecutorTestsBase
|
||||
|
||||
hadErrorContent.Should().BeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that when a workflow emits a RequestInfoEvent with FunctionCallContent data,
|
||||
/// the AgentResponseUpdate preserves the original FunctionCallContent type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Test_AsAgent_FunctionCallContentPreservedInRequestInfoAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string CallId = "test-call-id";
|
||||
const string FunctionName = "testFunction";
|
||||
FunctionCallContent originalContent = new(CallId, FunctionName);
|
||||
RequestEmittingAgent requestAgent = new(originalContent);
|
||||
ExecutorBinding agentBinding = requestAgent.BindAsExecutor(
|
||||
new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true });
|
||||
Workflow workflow = new WorkflowBuilder(agentBinding).Build();
|
||||
|
||||
// Act
|
||||
List<AgentResponseUpdate> updates = await workflow.AsAIAgent("WorkflowAgent")
|
||||
.RunStreamingAsync(new ChatMessage(ChatRole.User, "Hello"))
|
||||
.ToListAsync();
|
||||
|
||||
// Assert
|
||||
AgentResponseUpdate? updateWithFunctionCall = updates.FirstOrDefault(u =>
|
||||
u.RawRepresentation is RequestInfoEvent && u.Contents.Any(c => c is FunctionCallContent));
|
||||
|
||||
updateWithFunctionCall.Should().NotBeNull("a FunctionCallContent should be present in the response updates");
|
||||
FunctionCallContent retrievedContent = updateWithFunctionCall!.Contents
|
||||
.OfType<FunctionCallContent>()
|
||||
.Should().ContainSingle()
|
||||
.Which;
|
||||
|
||||
retrievedContent.CallId.Should().NotBe(CallId);
|
||||
retrievedContent.CallId.Should().EndWith($":{CallId}");
|
||||
retrievedContent.Name.Should().Be(FunctionName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that when a workflow emits a RequestInfoEvent with ToolApprovalRequestContent data,
|
||||
/// the AgentResponseUpdate preserves the original ToolApprovalRequestContent type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Test_AsAgent_ToolApprovalRequestContentPreservedInRequestInfoAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string RequestId = "test-request-id";
|
||||
McpServerToolCallContent mcpCall = new("call-id", "testToolName", "http://localhost");
|
||||
ToolApprovalRequestContent originalContent = new(RequestId, mcpCall);
|
||||
RequestEmittingAgent requestAgent = new(originalContent);
|
||||
ExecutorBinding agentBinding = requestAgent.BindAsExecutor(
|
||||
new AIAgentHostOptions { InterceptUserInputRequests = false, EmitAgentUpdateEvents = true });
|
||||
Workflow workflow = new WorkflowBuilder(agentBinding).Build();
|
||||
|
||||
// Act
|
||||
List<AgentResponseUpdate> updates = await workflow.AsAIAgent("WorkflowAgent")
|
||||
.RunStreamingAsync(new ChatMessage(ChatRole.User, "Hello"))
|
||||
.ToListAsync();
|
||||
|
||||
// Assert
|
||||
AgentResponseUpdate? updateWithUserInput = updates.FirstOrDefault(u =>
|
||||
u.RawRepresentation is RequestInfoEvent && u.Contents.Any(c => c is ToolApprovalRequestContent));
|
||||
|
||||
updateWithUserInput.Should().NotBeNull("a ToolApprovalRequestContent should be present in the response updates");
|
||||
ToolApprovalRequestContent retrievedContent = updateWithUserInput!.Contents
|
||||
.OfType<ToolApprovalRequestContent>()
|
||||
.Should().ContainSingle()
|
||||
.Which;
|
||||
|
||||
retrievedContent.Should().NotBeNull();
|
||||
retrievedContent.RequestId.Should().NotBe(RequestId);
|
||||
retrievedContent.RequestId.Should().EndWith($":{RequestId}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests the full roundtrip: workflow emits a request, external caller responds, workflow processes response.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Test_AsAgent_FunctionCallRoundtrip_ResponseIsProcessedAsync()
|
||||
{
|
||||
// Arrange: Create an agent that emits a FunctionCallContent request
|
||||
const string CallId = "roundtrip-call-id";
|
||||
const string FunctionName = "testFunction";
|
||||
FunctionCallContent requestContent = new(CallId, FunctionName);
|
||||
RequestEmittingAgent requestAgent = new(requestContent, completeOnResponse: true);
|
||||
ExecutorBinding agentBinding = requestAgent.BindAsExecutor(
|
||||
new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true });
|
||||
Workflow workflow = new WorkflowBuilder(agentBinding).Build();
|
||||
AIAgent agent = workflow.AsAIAgent("WorkflowAgent");
|
||||
|
||||
// Act 1: First call - should receive the FunctionCallContent request
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
List<AgentResponseUpdate> firstCallUpdates = await agent.RunStreamingAsync(
|
||||
new ChatMessage(ChatRole.User, "Start"),
|
||||
session).ToListAsync();
|
||||
|
||||
// Assert 1: We should have received a FunctionCallContent
|
||||
AgentResponseUpdate? updateWithRequest = firstCallUpdates.FirstOrDefault(u =>
|
||||
u.RawRepresentation is RequestInfoEvent && u.Contents.Any(c => c is FunctionCallContent));
|
||||
updateWithRequest.Should().NotBeNull("a FunctionCallContent should be present in the response updates");
|
||||
|
||||
FunctionCallContent receivedRequest = updateWithRequest!.Contents
|
||||
.OfType<FunctionCallContent>()
|
||||
.First();
|
||||
receivedRequest.CallId.Should().EndWith($":{CallId}");
|
||||
|
||||
// Act 2: Send the response back
|
||||
FunctionResultContent responseContent = new(receivedRequest.CallId, "test result");
|
||||
ChatMessage responseMessage = new(ChatRole.Tool, [responseContent]);
|
||||
|
||||
// Act 2: Run the workflow with the response and capture the resulting updates
|
||||
List<AgentResponseUpdate> secondCallUpdates = await agent.RunStreamingAsync(responseMessage, session).ToListAsync();
|
||||
|
||||
// Assert 2: The response should be processed and the original request should no longer be pending.
|
||||
// Concretely, the workflow should not re-emit a FunctionCallContent with the same CallId.
|
||||
secondCallUpdates.Should().NotBeNull("processing the response should produce updates");
|
||||
secondCallUpdates.Should().NotBeEmpty("processing the response should progress the workflow");
|
||||
secondCallUpdates
|
||||
.Where(u => u.RawRepresentation is RequestInfoEvent)
|
||||
.SelectMany(u => u.Contents.OfType<FunctionCallContent>())
|
||||
.Should()
|
||||
.NotContain(c => c.CallId == receivedRequest.CallId, "the external FunctionCallContent request should be cleared after processing the response");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests the full roundtrip for ToolApprovalRequestContent: workflow emits request, external caller responds.
|
||||
/// Verifying inbound ToolApprovalResponseContent conversion.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Test_AsAgent_ToolApprovalRoundtrip_ResponseIsProcessedAsync()
|
||||
{
|
||||
// Arrange: Create an agent that emits a ToolApprovalRequestContent request
|
||||
const string RequestId = "roundtrip-request-id";
|
||||
McpServerToolCallContent mcpCall = new("mcp-call-id", "testMcpTool", "http://localhost");
|
||||
ToolApprovalRequestContent requestContent = new(RequestId, mcpCall);
|
||||
RequestEmittingAgent requestAgent = new(requestContent, completeOnResponse: true);
|
||||
ExecutorBinding agentBinding = requestAgent.BindAsExecutor(
|
||||
new AIAgentHostOptions { InterceptUserInputRequests = false, EmitAgentUpdateEvents = true });
|
||||
Workflow workflow = new WorkflowBuilder(agentBinding).Build();
|
||||
AIAgent agent = workflow.AsAIAgent("WorkflowAgent");
|
||||
|
||||
// Act 1: First call - should receive the ToolApprovalRequestContent request
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
List<AgentResponseUpdate> firstCallUpdates = await agent.RunStreamingAsync(
|
||||
new ChatMessage(ChatRole.User, "Start"),
|
||||
session).ToListAsync();
|
||||
|
||||
// Assert 1: We should have received a ToolApprovalRequestContent
|
||||
AgentResponseUpdate? updateWithRequest = firstCallUpdates.FirstOrDefault(u =>
|
||||
u.RawRepresentation is RequestInfoEvent && u.Contents.Any(c => c is ToolApprovalRequestContent));
|
||||
updateWithRequest.Should().NotBeNull("a ToolApprovalRequestContent should be present in the response updates");
|
||||
|
||||
ToolApprovalRequestContent receivedRequest = updateWithRequest!.Contents
|
||||
.OfType<ToolApprovalRequestContent>()
|
||||
.First();
|
||||
receivedRequest.RequestId.Should().EndWith($":{RequestId}");
|
||||
|
||||
// Act 2: Send the response back - use CreateResponse to get the right response type
|
||||
ToolApprovalResponseContent responseContent = receivedRequest.CreateResponse(approved: true);
|
||||
ChatMessage responseMessage = new(ChatRole.User, [responseContent]);
|
||||
|
||||
// Act 2: Run the workflow again with the response and capture the updates
|
||||
List<AgentResponseUpdate> secondCallUpdates = await agent.RunStreamingAsync(responseMessage, session).ToListAsync();
|
||||
|
||||
// Assert 2: The response should be applied so that the original request is no longer pending
|
||||
secondCallUpdates.Should().NotBeEmpty("handling the user input response should produce follow-up updates");
|
||||
bool requestStillPresent = secondCallUpdates.Any(u =>
|
||||
u.RawRepresentation is RequestInfoEvent
|
||||
&& u.Contents.OfType<ToolApprovalRequestContent>().Any(r => r.RequestId == receivedRequest.RequestId));
|
||||
requestStillPresent.Should().BeFalse("the original ToolApprovalRequestContent should not be re-emitted after its response is processed");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests the mixed-message scenario: resume contains both an external response
|
||||
/// (FunctionResultContent matching a pending request) and regular non-response content
|
||||
/// in the same message.
|
||||
/// Verifies that regular content is still processed and that no duplicate
|
||||
/// pending-request errors, redundant FunctionCallContent re-emissions,
|
||||
/// or workflow errors occur.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Test_AsAgent_MixedResponseAndRegularMessage_BothProcessedAsync()
|
||||
{
|
||||
// Arrange: Create an agent that emits a FunctionCallContent request
|
||||
const string CallId = "mixed-call-id";
|
||||
const string FunctionName = "mixedTestFunction";
|
||||
FunctionCallContent requestContent = new(CallId, FunctionName);
|
||||
RequestEmittingAgent requestAgent = new(requestContent, completeOnResponse: true);
|
||||
ExecutorBinding agentBinding = requestAgent.BindAsExecutor(
|
||||
new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true });
|
||||
Workflow workflow = new WorkflowBuilder(agentBinding).Build();
|
||||
AIAgent agent = workflow.AsAIAgent("WorkflowAgent");
|
||||
|
||||
// Act 1: First call - should receive the FunctionCallContent request
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
List<AgentResponseUpdate> firstCallUpdates = await agent.RunStreamingAsync(
|
||||
new ChatMessage(ChatRole.User, "Start"),
|
||||
session).ToListAsync();
|
||||
|
||||
// Assert 1: We should have received a FunctionCallContent
|
||||
AgentResponseUpdate requestUpdate = firstCallUpdates.First(u =>
|
||||
u.RawRepresentation is RequestInfoEvent && u.Contents.Any(c => c is FunctionCallContent));
|
||||
FunctionCallContent emittedRequest = requestUpdate.Contents.OfType<FunctionCallContent>().Single();
|
||||
|
||||
firstCallUpdates.Should().Contain(u => u.Contents.Any(c => c is FunctionCallContent),
|
||||
"the first call should emit a FunctionCallContent request");
|
||||
|
||||
// Act 2: Send a mixed message containing both the function result AND regular non-response content
|
||||
FunctionResultContent responseContent = new(emittedRequest.CallId, "tool output");
|
||||
ChatMessage mixedMessage = new(ChatRole.Tool, [responseContent, new TextContent("additional context")]);
|
||||
|
||||
List<AgentResponseUpdate> secondCallUpdates = await agent.RunStreamingAsync(mixedMessage, session).ToListAsync();
|
||||
|
||||
// Assert 2: The workflow should have processed both parts without errors
|
||||
secondCallUpdates.Should().NotBeEmpty("the mixed message should produce follow-up updates");
|
||||
secondCallUpdates
|
||||
.Where(u => u.RawRepresentation is RequestInfoEvent)
|
||||
.SelectMany(u => u.Contents.OfType<FunctionCallContent>())
|
||||
.Should()
|
||||
.NotContain(c => c.CallId == emittedRequest.CallId, "the external FunctionCallContent should be cleared after the response is processed");
|
||||
secondCallUpdates
|
||||
.SelectMany(u => u.Contents.OfType<ErrorContent>())
|
||||
.Should()
|
||||
.BeEmpty("no workflow errors should occur when processing a mixed response-and-regular message");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_AsAgent_ResponseThenRegularAcrossMessages_NoDuplicateFunctionCallAsync()
|
||||
{
|
||||
const string CallId = "mixed-separate-call-id";
|
||||
const string FunctionName = "mixedSeparateTestFunction";
|
||||
|
||||
RequestEmittingAgent requestAgent = new(new FunctionCallContent(CallId, FunctionName), completeOnResponse: true);
|
||||
ExecutorBinding agentBinding = requestAgent.BindAsExecutor(
|
||||
new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true });
|
||||
Workflow workflow = new WorkflowBuilder(agentBinding).Build();
|
||||
AIAgent agent = workflow.AsAIAgent("WorkflowAgent");
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
List<AgentResponseUpdate> firstCallUpdates = await agent.RunStreamingAsync(new ChatMessage(ChatRole.User, "Start"), session).ToListAsync();
|
||||
FunctionCallContent emittedRequest = firstCallUpdates
|
||||
.Where(u => u.RawRepresentation is RequestInfoEvent)
|
||||
.SelectMany(u => u.Contents.OfType<FunctionCallContent>())
|
||||
.Single();
|
||||
|
||||
ChatMessage[] resumeMessages =
|
||||
[
|
||||
new(ChatRole.Tool, [new FunctionResultContent(emittedRequest.CallId, "tool output")]),
|
||||
new(ChatRole.Tool, [new TextContent("extra context in separate message")])
|
||||
];
|
||||
|
||||
List<AgentResponseUpdate> secondCallUpdates = await agent.RunStreamingAsync(resumeMessages, session).ToListAsync();
|
||||
|
||||
secondCallUpdates.Should().NotBeEmpty();
|
||||
secondCallUpdates
|
||||
.Where(u => u.RawRepresentation is RequestInfoEvent)
|
||||
.SelectMany(u => u.Contents.OfType<FunctionCallContent>())
|
||||
.Should()
|
||||
.NotContain(c => c.CallId == emittedRequest.CallId, "response+regular content split across messages should not re-emit the handled external request");
|
||||
secondCallUpdates
|
||||
.SelectMany(u => u.Contents.OfType<ErrorContent>())
|
||||
.Should()
|
||||
.BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_AsAgent_MatchingResponse_DoesNotCauseExtraTurnAsync()
|
||||
{
|
||||
const string CallId = "matching-response-call-id";
|
||||
const string FunctionName = "matchingResponseFunction";
|
||||
|
||||
RequestEmittingAgent requestAgent = new(new FunctionCallContent(CallId, FunctionName), completeOnResponse: false);
|
||||
ExecutorBinding agentBinding = requestAgent.BindAsExecutor(
|
||||
new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true });
|
||||
Workflow workflow = new WorkflowBuilder(agentBinding).Build();
|
||||
AIAgent agent = workflow.AsAIAgent("WorkflowAgent");
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
List<AgentResponseUpdate> firstCallUpdates = await agent.RunStreamingAsync(new ChatMessage(ChatRole.User, "Start"), session).ToListAsync();
|
||||
FunctionCallContent emittedRequest = firstCallUpdates
|
||||
.Where(u => u.RawRepresentation is RequestInfoEvent)
|
||||
.SelectMany(u => u.Contents.OfType<FunctionCallContent>())
|
||||
.Single();
|
||||
|
||||
List<AgentResponseUpdate> secondCallUpdates = await agent.RunStreamingAsync(
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent(emittedRequest.CallId, "tool output")]),
|
||||
session).ToListAsync();
|
||||
|
||||
int functionCallCount = secondCallUpdates
|
||||
.Where(u => u.RawRepresentation is RequestInfoEvent)
|
||||
.SelectMany(u => u.Contents.OfType<FunctionCallContent>())
|
||||
.Count(c => c.CallId == emittedRequest.CallId);
|
||||
|
||||
functionCallCount.Should().Be(1, "a matching external response should not trigger an extra TurnToken-driven turn");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_AsAgent_MixedResponseAndRegularMessage_CrossExecutorStartExecutorIsReawakenedAsync()
|
||||
{
|
||||
const string StartExecutorId = "start-executor";
|
||||
const string KickoffInputText = "Start";
|
||||
const string KickoffMessageText = "kickoff downstream";
|
||||
const string ResumeRegularText = "resume regular";
|
||||
const string ResumeProcessedText = "regular message processed";
|
||||
const string CallId = "cross-executor-call-id";
|
||||
const string FunctionName = "crossExecutorFunction";
|
||||
|
||||
RequestEmittingAgent requestAgent = new(new FunctionCallContent(CallId, FunctionName), completeOnResponse: true);
|
||||
ExecutorBinding requestBinding = requestAgent.BindAsExecutor(
|
||||
new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true });
|
||||
|
||||
KickoffOnStartExecutor startExecutor = new(
|
||||
StartExecutorId,
|
||||
requestBinding.Id,
|
||||
KickoffInputText,
|
||||
KickoffMessageText,
|
||||
ResumeRegularText,
|
||||
ResumeProcessedText);
|
||||
ExecutorBinding startBinding = startExecutor.BindExecutor();
|
||||
|
||||
Workflow workflow = new WorkflowBuilder(startBinding)
|
||||
.AddEdge<List<ChatMessage>>(startBinding, requestBinding, messages =>
|
||||
messages?.Any(message => message.Contents.OfType<TextContent>().Any(content => content.Text == KickoffMessageText)) == true)
|
||||
.AddEdge<TurnToken>(startBinding, requestBinding, _ => true)
|
||||
.Build();
|
||||
AIAgent agent = workflow.AsAIAgent("WorkflowAgent");
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
List<AgentResponseUpdate> firstCallUpdates = await agent.RunStreamingAsync(
|
||||
new ChatMessage(ChatRole.User, KickoffInputText),
|
||||
session).ToListAsync();
|
||||
FunctionCallContent emittedRequest = firstCallUpdates
|
||||
.Where(u => u.RawRepresentation is RequestInfoEvent)
|
||||
.SelectMany(u => u.Contents.OfType<FunctionCallContent>())
|
||||
.Single();
|
||||
|
||||
ChatMessage[] resumeMessages =
|
||||
[
|
||||
new(ChatRole.Tool, [new FunctionResultContent(emittedRequest.CallId, "tool output")]),
|
||||
new(ChatRole.User, ResumeRegularText)
|
||||
];
|
||||
|
||||
List<AgentResponseUpdate> secondCallUpdates = await agent.RunStreamingAsync(resumeMessages, session).ToListAsync();
|
||||
List<string> textContents = [.. secondCallUpdates.SelectMany(update => update.Contents.OfType<TextContent>()).Select(content => content.Text)];
|
||||
|
||||
textContents.Should().Contain(ResumeProcessedText, "the start executor should receive an explicit TurnToken when the matched response wakes a different executor");
|
||||
textContents.Should().Contain("Request processed", "the matched external response should still be delivered to the downstream request owner");
|
||||
secondCallUpdates
|
||||
.Where(u => u.RawRepresentation is RequestInfoEvent)
|
||||
.SelectMany(u => u.Contents.OfType<FunctionCallContent>())
|
||||
.Should()
|
||||
.NotContain(c => c.CallId == emittedRequest.CallId, "the handled external request should not be re-emitted while waking the start executor");
|
||||
secondCallUpdates.SelectMany(u => u.Contents.OfType<ErrorContent>()).Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_AsAgent_UnmatchedResponse_TriggersTurnAndKeepsProgressingAsync()
|
||||
{
|
||||
const string CallId = "unmatched-response-call-id";
|
||||
const string FunctionName = "unmatchedResponseFunction";
|
||||
|
||||
RequestEmittingAgent requestAgent = new(new FunctionCallContent(CallId, FunctionName), completeOnResponse: false);
|
||||
ExecutorBinding agentBinding = requestAgent.BindAsExecutor(
|
||||
new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true });
|
||||
Workflow workflow = new WorkflowBuilder(agentBinding).Build();
|
||||
AIAgent agent = workflow.AsAIAgent("WorkflowAgent");
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
List<AgentResponseUpdate> firstCallUpdates = await agent.RunStreamingAsync(new ChatMessage(ChatRole.User, "Start"), session).ToListAsync();
|
||||
firstCallUpdates.Should().Contain(u => u.Contents.Any(c => c is FunctionCallContent));
|
||||
|
||||
List<AgentResponseUpdate> secondCallUpdates = await agent.RunStreamingAsync(
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("different-call-id", "tool output")]),
|
||||
session).ToListAsync();
|
||||
|
||||
int functionCallCount = secondCallUpdates
|
||||
.SelectMany(u => u.Contents.OfType<FunctionCallContent>())
|
||||
.Count(c => c.CallId == CallId);
|
||||
|
||||
functionCallCount.Should().Be(1, "an unmatched response should be treated as regular input and still drive a TurnToken continuation without workflow errors");
|
||||
secondCallUpdates.SelectMany(u => u.Contents.OfType<ErrorContent>()).Should().BeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that when a resume contains only an external response directed at a non-start executor
|
||||
/// (no regular messages), the start executor still receives a TurnToken and is activated.
|
||||
/// This is a regression test for the case where the TurnToken was previously skipped because
|
||||
/// <c>HasRegularMessages</c> was <see langword="false"/>, leaving the start executor dormant.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Test_AsAgent_ResponseOnlyToNonStartExecutor_StartExecutorIsStillActivatedAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string StartExecutorId = "start-executor";
|
||||
const string ActivatedMarker = "start-executor-activated";
|
||||
const string CallId = "response-only-call-id";
|
||||
const string FunctionName = "responseOnlyFunction";
|
||||
|
||||
RequestEmittingAgent requestAgent = new(new FunctionCallContent(CallId, FunctionName), completeOnResponse: true);
|
||||
ExecutorBinding requestBinding = requestAgent.BindAsExecutor(
|
||||
new AIAgentHostOptions { InterceptUnterminatedFunctionCalls = false, EmitAgentUpdateEvents = true });
|
||||
|
||||
TurnTrackingStartExecutor startExecutor = new(StartExecutorId, requestBinding.Id, ActivatedMarker);
|
||||
ExecutorBinding startBinding = startExecutor.BindExecutor();
|
||||
|
||||
Workflow workflow = new WorkflowBuilder(startBinding)
|
||||
.AddEdge<List<ChatMessage>>(startBinding, requestBinding, messages =>
|
||||
messages?.Any(m => m.Contents.OfType<TextContent>().Any()) == true)
|
||||
.AddEdge<TurnToken>(startBinding, requestBinding, _ => true)
|
||||
.Build();
|
||||
AIAgent agent = workflow.AsAIAgent("WorkflowAgent");
|
||||
|
||||
// Act 1: First call triggers the downstream FunctionCallContent request
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
List<AgentResponseUpdate> firstCallUpdates = await agent.RunStreamingAsync(
|
||||
new ChatMessage(ChatRole.User, "Start"),
|
||||
session).ToListAsync();
|
||||
|
||||
FunctionCallContent emittedRequest = firstCallUpdates
|
||||
.Where(u => u.RawRepresentation is RequestInfoEvent)
|
||||
.SelectMany(u => u.Contents.OfType<FunctionCallContent>())
|
||||
.Single();
|
||||
|
||||
// Act 2: Resume with ONLY the external response (no regular messages)
|
||||
List<AgentResponseUpdate> secondCallUpdates = await agent.RunStreamingAsync(
|
||||
new ChatMessage(ChatRole.Tool, [new FunctionResultContent(emittedRequest.CallId, "tool output")]),
|
||||
session).ToListAsync();
|
||||
|
||||
// Assert: Both the downstream and start executor should have been activated
|
||||
List<string> textContents = [.. secondCallUpdates
|
||||
.SelectMany(u => u.Contents.OfType<TextContent>())
|
||||
.Select(c => c.Text)];
|
||||
|
||||
textContents.Should().Contain("Request processed",
|
||||
"the downstream executor should process the external response");
|
||||
textContents.Should().Contain(ActivatedMarker,
|
||||
"the start executor should receive a TurnToken and be activated even when resume contains only an external response");
|
||||
secondCallUpdates
|
||||
.SelectMany(u => u.Contents.OfType<ErrorContent>())
|
||||
.Should()
|
||||
.BeEmpty();
|
||||
}
|
||||
|
||||
private async Task Run_AsAgent_OutgoingMessagesInHistoryAsync(Workflow workflow, bool runAsync)
|
||||
{
|
||||
// Arrange
|
||||
AIAgent workflowAgent = workflow.AsAIAgent();
|
||||
|
||||
// Act
|
||||
AgentSession session = await workflowAgent.CreateSessionAsync();
|
||||
AgentResponse response;
|
||||
if (runAsync)
|
||||
{
|
||||
List<AgentResponseUpdate> updates = [];
|
||||
await foreach (AgentResponseUpdate update in workflowAgent.RunStreamingAsync(session))
|
||||
{
|
||||
// Skip WorkflowEvent updates, which do not get persisted in ChatHistory; we cannot skip
|
||||
// them after because of a deleterious interaction with .ToAgentResponse() due to the
|
||||
// empty initial message (which is created without a MessageId). When running through the
|
||||
// message merger, it does the right thing internally.
|
||||
if (!string.IsNullOrEmpty(update.Text))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
}
|
||||
|
||||
response = updates.ToAgentResponse();
|
||||
}
|
||||
else
|
||||
{
|
||||
response = await workflowAgent.RunAsync(session);
|
||||
}
|
||||
|
||||
// Assert
|
||||
WorkflowSession workflowSession = session.Should().BeOfType<WorkflowSession>().Subject;
|
||||
|
||||
ChatMessage[] responseMessages = response.Messages.Where(message => message.Contents.Any())
|
||||
.ToArray();
|
||||
|
||||
ChatMessage[] sessionMessages = workflowSession.ChatHistoryProvider.GetAllMessages(workflowSession)
|
||||
.ToArray();
|
||||
|
||||
// Since we never sent an incoming message, the expectation is that there should be nothing in the session
|
||||
// except the response
|
||||
responseMessages.Should().BeEquivalentTo(sessionMessages, options => options.WithStrictOrdering());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public Task Test_SingleAgent_AsAgent_OutgoingMessagesInHistoryAsync(bool runAsync)
|
||||
{
|
||||
// Arrange
|
||||
TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName);
|
||||
Workflow singleAgentWorkflow = new WorkflowBuilder(agent).Build();
|
||||
return this.Run_AsAgent_OutgoingMessagesInHistoryAsync(singleAgentWorkflow, runAsync);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public Task Test_Handoffs_AsAgent_OutgoingMessagesInHistoryAsync(bool runAsync)
|
||||
{
|
||||
// Arrange
|
||||
TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName);
|
||||
Workflow handoffWorkflow = new HandoffsWorkflowBuilder(agent).Build();
|
||||
return this.Run_AsAgent_OutgoingMessagesInHistoryAsync(handoffWorkflow, runAsync);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,10 +25,8 @@ classifiers = [
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0rc5",
|
||||
"agent-framework-openai>=1.0.0rc5",
|
||||
"azure-ai-projects>=2.0.0,<3.0",
|
||||
"azure-ai-agents>=1.2.0b5,<1.2.0b6",
|
||||
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
|
||||
"azure-identity>=1,<2",
|
||||
"aiohttp>=3.7.0,<4",
|
||||
]
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ from typing import (
|
||||
)
|
||||
from uuid import uuid4
|
||||
|
||||
from mcp import types
|
||||
from mcp.server.lowlevel import Server
|
||||
from mcp.shared.exceptions import McpError
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import _tools as _tool_utils # pyright: ignore[reportPrivateUsage]
|
||||
@@ -68,9 +71,6 @@ else:
|
||||
from typing_extensions import Self, TypedDict # pragma: no cover
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp import types
|
||||
from mcp.server.lowlevel import Server
|
||||
|
||||
from ._compaction import CompactionStrategy, TokenizerProtocol
|
||||
from ._types import ChatOptions
|
||||
|
||||
@@ -1369,15 +1369,6 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
|
||||
Returns:
|
||||
The MCP server instance.
|
||||
"""
|
||||
try:
|
||||
from mcp import types
|
||||
from mcp.server.lowlevel import Server
|
||||
from mcp.shared.exceptions import McpError
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ModuleNotFoundError(
|
||||
"`mcp` is required to use `Agent.as_mcp_server()`. Please install `mcp`."
|
||||
) from exc
|
||||
|
||||
server_args: dict[str, Any] = {
|
||||
"name": server_name,
|
||||
"version": version,
|
||||
@@ -1478,8 +1469,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
|
||||
|
||||
|
||||
class Agent(
|
||||
AgentMiddlewareLayer,
|
||||
AgentTelemetryLayer,
|
||||
AgentMiddlewareLayer,
|
||||
RawAgent[OptionsCoT],
|
||||
Generic[OptionsCoT],
|
||||
):
|
||||
|
||||
@@ -13,12 +13,25 @@ from collections.abc import Callable, Collection, Sequence
|
||||
from contextlib import AsyncExitStack, _AsyncGeneratorContextManager # type: ignore
|
||||
from datetime import timedelta
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast
|
||||
from typing import TYPE_CHECKING, Any, Literal, TypedDict
|
||||
|
||||
import httpx
|
||||
from anyio import ClosedResourceError
|
||||
from mcp import types
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.client.stdio import StdioServerParameters, stdio_client
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.client.websocket import websocket_client
|
||||
from mcp.shared.context import RequestContext
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.shared.session import RequestResponder
|
||||
from opentelemetry import propagate
|
||||
|
||||
from ._tools import FunctionTool
|
||||
from ._types import Content, Message
|
||||
from ._types import (
|
||||
Content,
|
||||
Message,
|
||||
)
|
||||
from .exceptions import ToolException, ToolExecutionException
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
@@ -27,18 +40,9 @@ else:
|
||||
from typing_extensions import Self # pragma: no cover
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from httpx import AsyncClient
|
||||
from mcp import types
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.shared.context import RequestContext
|
||||
from mcp.shared.session import RequestResponder
|
||||
|
||||
from ._clients import SupportsChatGetResponse
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MCPSpecificApproval(TypedDict, total=False):
|
||||
"""Represents the specific approval mode for an MCP tool.
|
||||
|
||||
@@ -53,12 +57,13 @@ class MCPSpecificApproval(TypedDict, total=False):
|
||||
never_require_approval: Collection[str] | None
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_MCP_REMOTE_NAME_KEY = "_mcp_remote_name"
|
||||
_MCP_NORMALIZED_NAME_KEY = "_mcp_normalized_name"
|
||||
|
||||
# region: Helpers
|
||||
|
||||
LOG_LEVEL_MAPPING: dict[str, int] = {
|
||||
LOG_LEVEL_MAPPING: dict[types.LoggingLevel, int] = {
|
||||
"debug": logging.DEBUG,
|
||||
"info": logging.INFO,
|
||||
"notice": logging.INFO,
|
||||
@@ -70,6 +75,269 @@ LOG_LEVEL_MAPPING: dict[str, int] = {
|
||||
}
|
||||
|
||||
|
||||
def _parse_prompt_result_from_mcp(
|
||||
mcp_type: types.GetPromptResult,
|
||||
) -> str:
|
||||
"""Parse an MCP GetPromptResult directly into a string representation.
|
||||
|
||||
Converts each message in the prompt result to its string form and combines them.
|
||||
|
||||
Args:
|
||||
mcp_type: The MCP GetPromptResult object to convert.
|
||||
|
||||
Returns:
|
||||
A string representation of the prompt result.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
for message in mcp_type.messages:
|
||||
content = message.content
|
||||
if isinstance(content, types.TextContent):
|
||||
parts.append(content.text)
|
||||
elif isinstance(content, (types.ImageContent, types.AudioContent)):
|
||||
parts.append(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "image" if isinstance(content, types.ImageContent) else "audio",
|
||||
"data": content.data,
|
||||
"mimeType": content.mimeType,
|
||||
},
|
||||
default=str,
|
||||
)
|
||||
)
|
||||
elif isinstance(content, types.EmbeddedResource):
|
||||
match content.resource:
|
||||
case types.TextResourceContents():
|
||||
parts.append(content.resource.text)
|
||||
case types.BlobResourceContents():
|
||||
parts.append(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "blob",
|
||||
"data": content.resource.blob,
|
||||
"mimeType": content.resource.mimeType,
|
||||
},
|
||||
default=str,
|
||||
)
|
||||
)
|
||||
else:
|
||||
parts.append(str(content))
|
||||
if not parts:
|
||||
return ""
|
||||
if len(parts) == 1:
|
||||
return parts[0]
|
||||
return json.dumps(parts, default=str)
|
||||
|
||||
|
||||
def _parse_message_from_mcp(
|
||||
mcp_type: types.PromptMessage | types.SamplingMessage,
|
||||
) -> Message:
|
||||
"""Parse an MCP container type into an Agent Framework type."""
|
||||
return Message(
|
||||
role=mcp_type.role,
|
||||
contents=_parse_content_from_mcp(mcp_type.content),
|
||||
raw_representation=mcp_type,
|
||||
)
|
||||
|
||||
|
||||
def _parse_tool_result_from_mcp(
|
||||
mcp_type: types.CallToolResult,
|
||||
) -> list[Content]:
|
||||
"""Parse an MCP CallToolResult into a list of Content items.
|
||||
|
||||
Converts each content item in the MCP result to its appropriate
|
||||
Content form. Text items become ``Content(type="text")`` and media
|
||||
items (images, audio) are preserved as rich Content.
|
||||
|
||||
Args:
|
||||
mcp_type: The MCP CallToolResult object to convert.
|
||||
|
||||
Returns:
|
||||
A list of Content items representing the tool result.
|
||||
"""
|
||||
result: list[Content] = []
|
||||
for item in mcp_type.content:
|
||||
match item:
|
||||
case types.TextContent():
|
||||
result.append(Content.from_text(item.text))
|
||||
case types.ImageContent() | types.AudioContent():
|
||||
decoded = base64.b64decode(item.data)
|
||||
result.append(
|
||||
Content.from_data(
|
||||
data=decoded,
|
||||
media_type=item.mimeType,
|
||||
)
|
||||
)
|
||||
case types.ResourceLink():
|
||||
result.append(
|
||||
Content.from_uri(
|
||||
uri=str(item.uri),
|
||||
media_type=item.mimeType,
|
||||
)
|
||||
)
|
||||
case types.EmbeddedResource():
|
||||
match item.resource:
|
||||
case types.TextResourceContents():
|
||||
result.append(Content.from_text(item.resource.text))
|
||||
case types.BlobResourceContents():
|
||||
blob = item.resource.blob
|
||||
mime = item.resource.mimeType or "application/octet-stream"
|
||||
if not blob.startswith("data:"):
|
||||
blob = f"data:{mime};base64,{blob}"
|
||||
result.append(
|
||||
Content.from_uri(
|
||||
uri=blob,
|
||||
media_type=mime,
|
||||
)
|
||||
)
|
||||
case _:
|
||||
result.append(Content.from_text(str(item)))
|
||||
|
||||
if not result:
|
||||
result.append(Content.from_text("null"))
|
||||
return result
|
||||
|
||||
|
||||
def _parse_content_from_mcp(
|
||||
mcp_type: types.ImageContent
|
||||
| types.TextContent
|
||||
| types.AudioContent
|
||||
| types.EmbeddedResource
|
||||
| types.ResourceLink
|
||||
| types.ToolUseContent
|
||||
| types.ToolResultContent
|
||||
| Sequence[
|
||||
types.ImageContent
|
||||
| types.TextContent
|
||||
| types.AudioContent
|
||||
| types.EmbeddedResource
|
||||
| types.ResourceLink
|
||||
| types.ToolUseContent
|
||||
| types.ToolResultContent
|
||||
],
|
||||
) -> list[Content]:
|
||||
"""Parse an MCP type into an Agent Framework type."""
|
||||
mcp_types = mcp_type if isinstance(mcp_type, Sequence) else [mcp_type]
|
||||
return_types: list[Content] = []
|
||||
for mcp_type in mcp_types:
|
||||
match mcp_type:
|
||||
case types.TextContent():
|
||||
return_types.append(Content.from_text(text=mcp_type.text, raw_representation=mcp_type))
|
||||
case types.ImageContent() | types.AudioContent():
|
||||
# MCP protocol uses base64-encoded strings, convert to bytes
|
||||
data_bytes = base64.b64decode(mcp_type.data) if isinstance(mcp_type.data, str) else mcp_type.data
|
||||
return_types.append(
|
||||
Content.from_data(
|
||||
data=data_bytes,
|
||||
media_type=mcp_type.mimeType,
|
||||
raw_representation=mcp_type,
|
||||
)
|
||||
)
|
||||
case types.ResourceLink():
|
||||
return_types.append(
|
||||
Content.from_uri(
|
||||
uri=str(mcp_type.uri),
|
||||
media_type=mcp_type.mimeType or "application/json",
|
||||
raw_representation=mcp_type,
|
||||
)
|
||||
)
|
||||
case types.ToolUseContent():
|
||||
return_types.append(
|
||||
Content.from_function_call(
|
||||
call_id=mcp_type.id,
|
||||
name=mcp_type.name,
|
||||
arguments=mcp_type.input,
|
||||
raw_representation=mcp_type,
|
||||
)
|
||||
)
|
||||
case types.ToolResultContent():
|
||||
return_types.append(
|
||||
Content.from_function_result(
|
||||
call_id=mcp_type.toolUseId,
|
||||
result=_parse_content_from_mcp(mcp_type.content)
|
||||
if mcp_type.content
|
||||
else mcp_type.structuredContent,
|
||||
exception=str(Exception()) if mcp_type.isError else None, # type: ignore[arg-type]
|
||||
raw_representation=mcp_type,
|
||||
)
|
||||
)
|
||||
case types.EmbeddedResource():
|
||||
match mcp_type.resource:
|
||||
case types.TextResourceContents():
|
||||
return_types.append(
|
||||
Content.from_text(
|
||||
text=mcp_type.resource.text,
|
||||
raw_representation=mcp_type,
|
||||
additional_properties=(
|
||||
mcp_type.annotations.model_dump() if mcp_type.annotations else None
|
||||
),
|
||||
)
|
||||
)
|
||||
case types.BlobResourceContents():
|
||||
return_types.append(
|
||||
Content.from_uri(
|
||||
uri=mcp_type.resource.blob,
|
||||
media_type=mcp_type.resource.mimeType,
|
||||
raw_representation=mcp_type,
|
||||
additional_properties=(
|
||||
mcp_type.annotations.model_dump() if mcp_type.annotations else None
|
||||
),
|
||||
)
|
||||
)
|
||||
return return_types
|
||||
|
||||
|
||||
def _prepare_content_for_mcp(
|
||||
content: Content,
|
||||
) -> types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource | types.ResourceLink | None:
|
||||
"""Prepare an Agent Framework content type for MCP."""
|
||||
if content.type == "text":
|
||||
return types.TextContent(type="text", text=content.text) # type: ignore[attr-defined]
|
||||
if content.type == "data":
|
||||
if content.media_type and content.media_type.startswith("image/"): # type: ignore[attr-defined]
|
||||
return types.ImageContent(type="image", data=content.uri, mimeType=content.media_type) # type: ignore[attr-defined]
|
||||
if content.media_type and content.media_type.startswith("audio/"): # type: ignore[attr-defined]
|
||||
return types.AudioContent(type="audio", data=content.uri, mimeType=content.media_type) # type: ignore[attr-defined]
|
||||
if content.media_type and content.media_type.startswith("application/"): # type: ignore[attr-defined]
|
||||
return types.EmbeddedResource(
|
||||
type="resource",
|
||||
resource=types.BlobResourceContents(
|
||||
blob=content.uri, # type: ignore[attr-defined]
|
||||
mimeType=content.media_type, # type: ignore[attr-defined]
|
||||
# uri's are not limited in MCP but they have to be set.
|
||||
# the uri of data content, contains the data uri, which
|
||||
# is not the uri meant here, UriContent would match this.
|
||||
uri=(
|
||||
content.additional_properties.get("uri", "af://binary")
|
||||
if content.additional_properties
|
||||
else "af://binary"
|
||||
), # type: ignore[reportArgumentType]
|
||||
),
|
||||
)
|
||||
return None
|
||||
if content.type == "uri":
|
||||
return types.ResourceLink(
|
||||
type="resource_link",
|
||||
uri=content.uri, # type: ignore[reportArgumentType,attr-defined]
|
||||
mimeType=content.media_type, # type: ignore[attr-defined]
|
||||
name=(content.additional_properties.get("name", "Unknown") if content.additional_properties else "Unknown"),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _prepare_message_for_mcp(
|
||||
content: Message,
|
||||
) -> list[types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource | types.ResourceLink]:
|
||||
"""Prepare a Message for MCP format."""
|
||||
messages: list[
|
||||
types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource | types.ResourceLink
|
||||
] = []
|
||||
for item in content.contents:
|
||||
mcp_content = _prepare_content_for_mcp(item)
|
||||
if mcp_content:
|
||||
messages.append(mcp_content)
|
||||
return messages
|
||||
|
||||
|
||||
def _get_input_model_from_mcp_prompt(prompt: types.Prompt) -> dict[str, Any]:
|
||||
"""Get the input model from an MCP prompt.
|
||||
|
||||
@@ -194,8 +462,8 @@ class MCPTool:
|
||||
``Callable[[types.GetPromptResult], str]`` that overrides the default prompt
|
||||
result parsing. When ``None`` (the default), the built-in parser converts
|
||||
MCP prompt results to a string. If you need per-function result parsing,
|
||||
access the ``.functions`` list after connecting and set ``result_parser`` on
|
||||
individual ``FunctionTool`` instances.
|
||||
access the ``.functions`` list after connecting and set ``result_parser`` on
|
||||
individual ``FunctionTool`` instances.
|
||||
session: An existing MCP client session to use.
|
||||
request_timeout: Timeout in seconds for MCP requests.
|
||||
client: A chat client for sampling callbacks.
|
||||
@@ -227,264 +495,6 @@ class MCPTool:
|
||||
def __str__(self) -> str:
|
||||
return f"MCPTool(name={self.name}, description={self.description})"
|
||||
|
||||
def _parse_prompt_result_from_mcp(
|
||||
self,
|
||||
mcp_type: types.GetPromptResult,
|
||||
) -> str:
|
||||
"""Parse an MCP GetPromptResult directly into a string representation."""
|
||||
from mcp import types
|
||||
|
||||
parts: list[str] = []
|
||||
for message in mcp_type.messages:
|
||||
content = message.content
|
||||
if isinstance(content, types.TextContent):
|
||||
parts.append(content.text)
|
||||
elif isinstance(content, (types.ImageContent, types.AudioContent)):
|
||||
parts.append(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "image" if isinstance(content, types.ImageContent) else "audio",
|
||||
"data": content.data,
|
||||
"mimeType": content.mimeType,
|
||||
},
|
||||
default=str,
|
||||
)
|
||||
)
|
||||
elif isinstance(content, types.EmbeddedResource):
|
||||
match content.resource:
|
||||
case types.TextResourceContents():
|
||||
parts.append(content.resource.text)
|
||||
case types.BlobResourceContents():
|
||||
parts.append(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "blob",
|
||||
"data": content.resource.blob,
|
||||
"mimeType": content.resource.mimeType,
|
||||
},
|
||||
default=str,
|
||||
)
|
||||
)
|
||||
else:
|
||||
parts.append(str(content))
|
||||
if not parts:
|
||||
return ""
|
||||
if len(parts) == 1:
|
||||
return parts[0]
|
||||
return json.dumps(parts, default=str)
|
||||
|
||||
def _parse_message_from_mcp(
|
||||
self,
|
||||
mcp_type: types.PromptMessage | types.SamplingMessage,
|
||||
) -> Message:
|
||||
"""Parse an MCP container type into an Agent Framework type."""
|
||||
return Message(
|
||||
role=mcp_type.role,
|
||||
contents=self._parse_content_from_mcp(mcp_type.content),
|
||||
raw_representation=mcp_type,
|
||||
)
|
||||
|
||||
def _parse_tool_result_from_mcp(
|
||||
self,
|
||||
mcp_type: types.CallToolResult,
|
||||
) -> list[Content]:
|
||||
"""Parse an MCP CallToolResult into a list of Content items."""
|
||||
from mcp import types
|
||||
|
||||
result: list[Content] = []
|
||||
for item in mcp_type.content:
|
||||
match item:
|
||||
case types.TextContent():
|
||||
result.append(Content.from_text(item.text))
|
||||
case types.ImageContent() | types.AudioContent():
|
||||
decoded = base64.b64decode(item.data)
|
||||
result.append(
|
||||
Content.from_data(
|
||||
data=decoded,
|
||||
media_type=item.mimeType,
|
||||
)
|
||||
)
|
||||
case types.ResourceLink():
|
||||
result.append(
|
||||
Content.from_uri(
|
||||
uri=str(item.uri),
|
||||
media_type=item.mimeType,
|
||||
)
|
||||
)
|
||||
case types.EmbeddedResource():
|
||||
match item.resource:
|
||||
case types.TextResourceContents():
|
||||
result.append(Content.from_text(item.resource.text))
|
||||
case types.BlobResourceContents():
|
||||
blob = item.resource.blob
|
||||
mime = item.resource.mimeType or "application/octet-stream"
|
||||
if not blob.startswith("data:"):
|
||||
blob = f"data:{mime};base64,{blob}"
|
||||
result.append(
|
||||
Content.from_uri(
|
||||
uri=blob,
|
||||
media_type=mime,
|
||||
)
|
||||
)
|
||||
case _:
|
||||
result.append(Content.from_text(str(item)))
|
||||
|
||||
if not result:
|
||||
result.append(Content.from_text("null"))
|
||||
return result
|
||||
|
||||
def _parse_content_from_mcp(
|
||||
self,
|
||||
mcp_type: types.ImageContent
|
||||
| types.TextContent
|
||||
| types.AudioContent
|
||||
| types.EmbeddedResource
|
||||
| types.ResourceLink
|
||||
| types.ToolUseContent
|
||||
| types.ToolResultContent
|
||||
| Sequence[
|
||||
types.ImageContent
|
||||
| types.TextContent
|
||||
| types.AudioContent
|
||||
| types.EmbeddedResource
|
||||
| types.ResourceLink
|
||||
| types.ToolUseContent
|
||||
| types.ToolResultContent
|
||||
],
|
||||
) -> list[Content]:
|
||||
"""Parse an MCP type into an Agent Framework type."""
|
||||
from mcp import types
|
||||
|
||||
mcp_content_types: Sequence[Any] = (
|
||||
cast(Sequence[Any], mcp_type) if isinstance(mcp_type, Sequence) else [mcp_type]
|
||||
) # type: ignore[redundant-cast]
|
||||
return_types: list[Content] = []
|
||||
for mcp_type in mcp_content_types:
|
||||
match mcp_type:
|
||||
case types.TextContent():
|
||||
return_types.append(Content.from_text(text=mcp_type.text, raw_representation=mcp_type))
|
||||
case types.ImageContent() | types.AudioContent():
|
||||
data_bytes = base64.b64decode(mcp_type.data) if isinstance(mcp_type.data, str) else mcp_type.data
|
||||
return_types.append(
|
||||
Content.from_data(
|
||||
data=data_bytes,
|
||||
media_type=mcp_type.mimeType,
|
||||
raw_representation=mcp_type,
|
||||
)
|
||||
)
|
||||
case types.ResourceLink():
|
||||
return_types.append(
|
||||
Content.from_uri(
|
||||
uri=str(mcp_type.uri),
|
||||
media_type=mcp_type.mimeType or "application/json",
|
||||
raw_representation=mcp_type,
|
||||
)
|
||||
)
|
||||
case types.ToolUseContent():
|
||||
return_types.append(
|
||||
Content.from_function_call(
|
||||
call_id=mcp_type.id,
|
||||
name=mcp_type.name,
|
||||
arguments=mcp_type.input,
|
||||
raw_representation=mcp_type,
|
||||
)
|
||||
)
|
||||
case types.ToolResultContent():
|
||||
return_types.append(
|
||||
Content.from_function_result(
|
||||
call_id=mcp_type.toolUseId,
|
||||
result=self._parse_content_from_mcp(mcp_type.content)
|
||||
if mcp_type.content
|
||||
else mcp_type.structuredContent,
|
||||
exception=str(Exception()) if mcp_type.isError else None, # type: ignore[arg-type]
|
||||
raw_representation=mcp_type,
|
||||
)
|
||||
)
|
||||
case types.EmbeddedResource():
|
||||
match mcp_type.resource:
|
||||
case types.TextResourceContents():
|
||||
return_types.append(
|
||||
Content.from_text(
|
||||
text=mcp_type.resource.text,
|
||||
raw_representation=mcp_type,
|
||||
additional_properties=(
|
||||
mcp_type.annotations.model_dump() if mcp_type.annotations else None
|
||||
),
|
||||
)
|
||||
)
|
||||
case types.BlobResourceContents():
|
||||
return_types.append(
|
||||
Content.from_uri(
|
||||
uri=mcp_type.resource.blob,
|
||||
media_type=mcp_type.resource.mimeType,
|
||||
raw_representation=mcp_type,
|
||||
additional_properties=(
|
||||
mcp_type.annotations.model_dump() if mcp_type.annotations else None
|
||||
),
|
||||
)
|
||||
)
|
||||
case _:
|
||||
pass
|
||||
return return_types
|
||||
|
||||
def _prepare_content_for_mcp(
|
||||
self,
|
||||
content: Content,
|
||||
) -> (
|
||||
types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource | types.ResourceLink | None
|
||||
):
|
||||
"""Prepare an Agent Framework content type for MCP."""
|
||||
from mcp import types
|
||||
|
||||
if content.type == "text":
|
||||
return types.TextContent(type="text", text=content.text) # type: ignore[attr-defined]
|
||||
if content.type == "data":
|
||||
if content.media_type and content.media_type.startswith("image/"): # type: ignore[attr-defined]
|
||||
return types.ImageContent(type="image", data=content.uri, mimeType=content.media_type) # type: ignore[attr-defined]
|
||||
if content.media_type and content.media_type.startswith("audio/"): # type: ignore[attr-defined]
|
||||
return types.AudioContent(type="audio", data=content.uri, mimeType=content.media_type) # type: ignore[attr-defined]
|
||||
if content.media_type and content.media_type.startswith("application/"): # type: ignore[attr-defined]
|
||||
return types.EmbeddedResource(
|
||||
type="resource",
|
||||
resource=types.BlobResourceContents(
|
||||
blob=content.uri, # type: ignore[attr-defined]
|
||||
mimeType=content.media_type, # type: ignore[attr-defined]
|
||||
uri=(
|
||||
content.additional_properties.get("uri", "af://binary")
|
||||
if content.additional_properties
|
||||
else "af://binary"
|
||||
), # type: ignore[arg-type]
|
||||
),
|
||||
)
|
||||
return None
|
||||
if content.type == "uri":
|
||||
resource_name = (
|
||||
content.additional_properties.get("name", "Unknown") if content.additional_properties else "Unknown"
|
||||
)
|
||||
return types.ResourceLink(
|
||||
type="resource_link",
|
||||
uri=content.uri, # type: ignore[arg-type,attr-defined]
|
||||
mimeType=content.media_type, # type: ignore[attr-defined]
|
||||
name=resource_name,
|
||||
)
|
||||
return None
|
||||
|
||||
def _prepare_message_for_mcp(
|
||||
self,
|
||||
content: Message,
|
||||
) -> list[
|
||||
types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource | types.ResourceLink
|
||||
]:
|
||||
"""Prepare a Message for MCP format."""
|
||||
messages: list[
|
||||
types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource | types.ResourceLink
|
||||
] = []
|
||||
for item in content.contents:
|
||||
mcp_content = self._prepare_content_for_mcp(item)
|
||||
if mcp_content:
|
||||
messages.append(mcp_content)
|
||||
return messages
|
||||
|
||||
@property
|
||||
def functions(self) -> list[FunctionTool]:
|
||||
"""Get the list of functions that are allowed."""
|
||||
@@ -639,16 +649,8 @@ class MCPTool:
|
||||
error_msg = f"Failed to connect to MCP server: {ex}"
|
||||
raise ToolException(error_msg, inner_exception=ex) from ex
|
||||
try:
|
||||
try:
|
||||
from mcp.client.session import ClientSession as runtime_client_session
|
||||
except ModuleNotFoundError as ex:
|
||||
await self._safe_close_exit_stack()
|
||||
raise ToolException(
|
||||
"MCP support requires `mcp`. Please install `mcp`.",
|
||||
inner_exception=ex,
|
||||
) from ex
|
||||
session = await self._exit_stack.enter_async_context(
|
||||
runtime_client_session(
|
||||
ClientSession(
|
||||
read_stream=transport[0],
|
||||
write_stream=transport[1],
|
||||
read_timeout_seconds=(
|
||||
@@ -679,7 +681,7 @@ class MCPTool:
|
||||
error_msg = f"MCP server failed to initialize: {ex}"
|
||||
raise ToolException(error_msg, inner_exception=ex) from ex
|
||||
self.session = session
|
||||
elif self.session._request_id == 0: # type: ignore[attr-defined]
|
||||
elif self.session._request_id == 0: # type: ignore[reportPrivateUsage]
|
||||
# If the session is not initialized, we need to reinitialize it
|
||||
await self.session.initialize()
|
||||
logger.debug("Connected to MCP server: %s", self.session)
|
||||
@@ -693,10 +695,9 @@ class MCPTool:
|
||||
|
||||
if logger.level != logging.NOTSET:
|
||||
try:
|
||||
level_name = cast(
|
||||
Any, next(level for level, value in LOG_LEVEL_MAPPING.items() if value == logger.level)
|
||||
await self.session.set_logging_level(
|
||||
next(level for level, value in LOG_LEVEL_MAPPING.items() if value == logger.level)
|
||||
)
|
||||
await self.session.set_logging_level(level_name)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to set log level to %s", logger.level, exc_info=exc)
|
||||
|
||||
@@ -722,8 +723,6 @@ class MCPTool:
|
||||
Returns:
|
||||
Either a CreateMessageResult with the generated message or ErrorData if generation fails.
|
||||
"""
|
||||
from mcp import types
|
||||
|
||||
if not self.client:
|
||||
return types.ErrorData(
|
||||
code=types.INTERNAL_ERROR,
|
||||
@@ -732,7 +731,7 @@ class MCPTool:
|
||||
logger.debug("Sampling callback called with params: %s", params)
|
||||
messages: list[Message] = []
|
||||
for msg in params.messages:
|
||||
messages.append(self._parse_message_from_mcp(msg))
|
||||
messages.append(_parse_message_from_mcp(msg))
|
||||
try:
|
||||
response = await self.client.get_response(
|
||||
messages,
|
||||
@@ -750,7 +749,7 @@ class MCPTool:
|
||||
code=types.INTERNAL_ERROR,
|
||||
message="Failed to get chat message content.",
|
||||
)
|
||||
mcp_contents = self._prepare_message_for_mcp(response.messages[0])
|
||||
mcp_contents = _prepare_message_for_mcp(response.messages[0])
|
||||
# grab the first content that is of type TextContent or ImageContent
|
||||
mcp_content = next(
|
||||
(content for content in mcp_contents if isinstance(content, (types.TextContent, types.ImageContent))),
|
||||
@@ -799,8 +798,6 @@ class MCPTool:
|
||||
Args:
|
||||
message: The message from the MCP server (request responder, notification, or exception).
|
||||
"""
|
||||
from mcp import types
|
||||
|
||||
if isinstance(message, Exception):
|
||||
logger.error("Error from MCP server: %s", message, exc_info=message)
|
||||
return
|
||||
@@ -827,7 +824,7 @@ class MCPTool:
|
||||
):
|
||||
return "never_require"
|
||||
return None
|
||||
return self.approval_mode # type: ignore[return-value]
|
||||
return self.approval_mode # type: ignore[reportReturnType]
|
||||
|
||||
async def load_prompts(self) -> None:
|
||||
"""Load prompts from the MCP server.
|
||||
@@ -838,8 +835,6 @@ class MCPTool:
|
||||
Raises:
|
||||
ToolExecutionException: If the MCP server is not connected.
|
||||
"""
|
||||
from mcp import types
|
||||
|
||||
# Track existing function names to prevent duplicates
|
||||
existing_names = {func.name for func in self._functions}
|
||||
|
||||
@@ -888,8 +883,6 @@ class MCPTool:
|
||||
Raises:
|
||||
ToolExecutionException: If the MCP server is not connected.
|
||||
"""
|
||||
from mcp import types
|
||||
|
||||
# Track existing function names to prevent duplicates
|
||||
existing_names = {func.name for func in self._functions}
|
||||
|
||||
@@ -1003,9 +996,6 @@ class MCPTool:
|
||||
ToolExecutionException: If the MCP server is not connected, tools are not loaded,
|
||||
or the tool call fails.
|
||||
"""
|
||||
from anyio import ClosedResourceError
|
||||
from mcp.shared.exceptions import McpError
|
||||
|
||||
if not self.load_tools_flag:
|
||||
raise ToolExecutionException(
|
||||
"Tools are not loaded for this server, please set load_tools=True in the constructor."
|
||||
@@ -1035,7 +1025,8 @@ class MCPTool:
|
||||
# Inject OpenTelemetry trace context into MCP _meta for distributed tracing.
|
||||
otel_meta = _inject_otel_into_mcp_meta()
|
||||
|
||||
parser = self.parse_tool_results or self._parse_tool_result_from_mcp
|
||||
parser = self.parse_tool_results or _parse_tool_result_from_mcp
|
||||
|
||||
# Try the operation, reconnecting once if the connection is closed
|
||||
for attempt in range(2):
|
||||
try:
|
||||
@@ -1071,8 +1062,7 @@ class MCPTool:
|
||||
inner_exception=cl_ex,
|
||||
) from cl_ex
|
||||
except McpError as mcp_exc:
|
||||
error_message = mcp_exc.error.message
|
||||
raise ToolExecutionException(error_message, inner_exception=mcp_exc) from mcp_exc
|
||||
raise ToolExecutionException(mcp_exc.error.message, inner_exception=mcp_exc) from mcp_exc
|
||||
except Exception as ex:
|
||||
raise ToolExecutionException(f"Failed to call tool '{tool_name}'.", inner_exception=ex) from ex
|
||||
raise ToolExecutionException(f"Failed to call tool '{tool_name}' after retries.")
|
||||
@@ -1093,15 +1083,13 @@ class MCPTool:
|
||||
ToolExecutionException: If the MCP server is not connected, prompts are not loaded,
|
||||
or the prompt call fails.
|
||||
"""
|
||||
from anyio import ClosedResourceError
|
||||
from mcp.shared.exceptions import McpError
|
||||
|
||||
if not self.load_prompts_flag:
|
||||
raise ToolExecutionException(
|
||||
"Prompts are not loaded for this server, please set load_prompts=True in the constructor."
|
||||
)
|
||||
|
||||
parser = self.parse_prompt_results or self._parse_prompt_result_from_mcp
|
||||
parser = self.parse_prompt_results or _parse_prompt_result_from_mcp
|
||||
|
||||
# Try the operation, reconnecting once if the connection is closed
|
||||
for attempt in range(2):
|
||||
try:
|
||||
@@ -1127,8 +1115,7 @@ class MCPTool:
|
||||
inner_exception=cl_ex,
|
||||
) from cl_ex
|
||||
except McpError as mcp_exc:
|
||||
error_message = mcp_exc.error.message
|
||||
raise ToolExecutionException(error_message, inner_exception=mcp_exc) from mcp_exc
|
||||
raise ToolExecutionException(mcp_exc.error.message, inner_exception=mcp_exc) from mcp_exc
|
||||
except Exception as ex:
|
||||
raise ToolExecutionException(f"Failed to call prompt '{prompt_name}'.", inner_exception=ex) from ex
|
||||
raise ToolExecutionException(f"Failed to get prompt '{prompt_name}' after retries.")
|
||||
@@ -1302,11 +1289,6 @@ class MCPStdioTool(MCPTool):
|
||||
args["encoding"] = self.encoding
|
||||
if self._client_kwargs:
|
||||
args.update(self._client_kwargs)
|
||||
try:
|
||||
from mcp.client.stdio import StdioServerParameters, stdio_client
|
||||
except ModuleNotFoundError as ex:
|
||||
raise ModuleNotFoundError("`mcp` is required to use `MCPStdioTool`. Please install `mcp`.") from ex
|
||||
|
||||
return stdio_client(server=StdioServerParameters(**args))
|
||||
|
||||
|
||||
@@ -1351,7 +1333,7 @@ class MCPStreamableHTTPTool(MCPTool):
|
||||
terminate_on_close: bool | None = None,
|
||||
client: SupportsChatGetResponse | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
http_client: AsyncClient | None = None,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize the MCP streamable HTTP tool.
|
||||
@@ -1359,7 +1341,7 @@ class MCPStreamableHTTPTool(MCPTool):
|
||||
Note:
|
||||
The arguments are used to create a streamable HTTP client using the
|
||||
new ``mcp.client.streamable_http.streamable_http_client`` API.
|
||||
If an asyncClient is provided via ``http_client``, it will be used directly.
|
||||
If an httpx.AsyncClient is provided via ``http_client``, it will be used directly.
|
||||
Otherwise, the ``streamable_http_client`` API will create and manage a default client.
|
||||
|
||||
Args:
|
||||
@@ -1395,10 +1377,10 @@ class MCPStreamableHTTPTool(MCPTool):
|
||||
additional_properties: Additional properties.
|
||||
terminate_on_close: Close the transport when the MCP client is terminated.
|
||||
client: The chat client to use for sampling.
|
||||
http_client: Optional asyncClient to use. If not provided, the
|
||||
http_client: Optional httpx.AsyncClient to use. If not provided, the
|
||||
``streamable_http_client`` API will create and manage a default client.
|
||||
To configure headers, timeouts, or other HTTP client settings, create
|
||||
and pass your own ``asyncClient`` instance.
|
||||
and pass your own ``httpx.AsyncClient`` instance.
|
||||
kwargs: Additional keyword arguments (accepted for backward compatibility but not used).
|
||||
"""
|
||||
super().__init__(
|
||||
@@ -1418,7 +1400,7 @@ class MCPStreamableHTTPTool(MCPTool):
|
||||
)
|
||||
self.url = url
|
||||
self.terminate_on_close = terminate_on_close
|
||||
self._httpx_client: AsyncClient | None = http_client
|
||||
self._httpx_client: httpx.AsyncClient | None = http_client
|
||||
|
||||
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
|
||||
"""Get an MCP streamable HTTP client.
|
||||
@@ -1426,11 +1408,6 @@ class MCPStreamableHTTPTool(MCPTool):
|
||||
Returns:
|
||||
An async context manager for the streamable HTTP client transport.
|
||||
"""
|
||||
try:
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
except ModuleNotFoundError as ex:
|
||||
raise ModuleNotFoundError("`mcp` is required to use `MCPStreamableHTTPTool`. Please install `mcp`.") from ex
|
||||
|
||||
# Pass the http_client (which may be None) to streamable_http_client
|
||||
return streamable_http_client(
|
||||
url=self.url,
|
||||
@@ -1545,21 +1522,6 @@ class MCPWebsocketTool(MCPTool):
|
||||
Returns:
|
||||
An async context manager for the WebSocket client transport.
|
||||
"""
|
||||
try:
|
||||
from mcp.client.websocket import websocket_client
|
||||
except ModuleNotFoundError as ex:
|
||||
missing_name = ex.name or "mcp/websocket dependencies"
|
||||
if missing_name == "mcp" or missing_name.startswith("mcp."):
|
||||
reason = "The `mcp` package is not installed."
|
||||
elif missing_name == "websockets" or missing_name.startswith("websockets."):
|
||||
reason = "WebSocket transport support is not installed."
|
||||
else:
|
||||
reason = f"The optional dependency `{missing_name}` is not installed."
|
||||
raise ModuleNotFoundError(
|
||||
f"`MCPWebsocketTool` requires websocket transport support. {reason} "
|
||||
"Please install `mcp[ws]` and update your dependencies."
|
||||
) from ex
|
||||
|
||||
args: dict[str, Any] = {
|
||||
"url": self.url,
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ from typing import TYPE_CHECKING, Any, ClassVar, Final, Generic, Literal, TypedD
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from opentelemetry import metrics, trace
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from opentelemetry.semconv.attributes import service_attributes
|
||||
from opentelemetry.semconv_ai import Meters
|
||||
|
||||
from . import __version__ as version_info
|
||||
from ._settings import load_settings
|
||||
@@ -40,7 +43,6 @@ if TYPE_CHECKING: # pragma: no cover
|
||||
from opentelemetry.sdk._logs.export import LogRecordExporter
|
||||
from opentelemetry.sdk.metrics.export import MetricExporter
|
||||
from opentelemetry.sdk.metrics.view import View
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from opentelemetry.sdk.trace.export import SpanExporter
|
||||
from opentelemetry.trace import Tracer
|
||||
from opentelemetry.util._decorator import _AgnosticContextManager # type: ignore[reportPrivateUsage]
|
||||
@@ -204,8 +206,6 @@ class OtelAttr(str, Enum):
|
||||
TOOL_RESULT = "gen_ai.tool.call.result"
|
||||
# Agent attributes
|
||||
AGENT_ID = "gen_ai.agent.id"
|
||||
SERVICE_NAME = "service.name"
|
||||
SERVICE_VERSION = "service.version"
|
||||
# Client attributes
|
||||
# replaced TOKEN with T, because both ruff and bandit,
|
||||
# complain about TOKEN being a potential secret
|
||||
@@ -214,8 +214,6 @@ class OtelAttr(str, Enum):
|
||||
T_TYPE_INPUT = "input"
|
||||
T_TYPE_OUTPUT = "output"
|
||||
DURATION_UNIT = "s"
|
||||
LLM_OPERATION_DURATION = "gen_ai.client.operation.duration"
|
||||
LLM_TOKEN_USAGE = "gen_ai.client.token.usage" # nosec B105 # noqa: S105 - OpenTelemetry metric name, not a secret.
|
||||
|
||||
# Agent attributes
|
||||
AGENT_NAME = "gen_ai.agent.name"
|
||||
@@ -226,6 +224,8 @@ class OtelAttr(str, Enum):
|
||||
INPUT_MESSAGES = "gen_ai.input.messages"
|
||||
OUTPUT_MESSAGES = "gen_ai.output.messages"
|
||||
SYSTEM_INSTRUCTIONS = "gen_ai.system_instructions"
|
||||
# Attributes previously from opentelemetry-semantic-conventions-ai SpanAttributes,
|
||||
# removed in v0.4.14. Defined here for forward compatibility.
|
||||
SYSTEM = "gen_ai.system"
|
||||
REQUEST_MAX_TOKENS = "gen_ai.request.max_tokens"
|
||||
REQUEST_TEMPERATURE = "gen_ai.request.temperature"
|
||||
@@ -282,7 +282,7 @@ class OtelAttr(str, Enum):
|
||||
CHAT_COMPLETION_OPERATION = "chat"
|
||||
EMBEDDING_OPERATION = "embeddings"
|
||||
TOOL_EXECUTION_OPERATION = "execute_tool"
|
||||
# Describes GenAI agent creation and is usually applicable when working with remote agent services.
|
||||
# Describes GenAI agent creation and is usually applicable when working with remote agent services.
|
||||
AGENT_CREATE_OPERATION = "create_agent"
|
||||
AGENT_INVOKE_OPERATION = "invoke_agent"
|
||||
|
||||
@@ -576,27 +576,25 @@ def create_resource(
|
||||
# Load from custom .env file
|
||||
resource = create_resource(env_file_path="config/.env")
|
||||
"""
|
||||
try:
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
except ModuleNotFoundError as ex:
|
||||
raise ModuleNotFoundError(
|
||||
"`opentelemetry-sdk` is required to use `create_resource()`. "
|
||||
"Please install `opentelemetry-sdk` and update your dependencies."
|
||||
) from ex
|
||||
|
||||
# Load environment variables from a .env file only when explicitly provided
|
||||
if env_file_path is not None:
|
||||
load_dotenv(dotenv_path=env_file_path, encoding=env_file_encoding)
|
||||
|
||||
# Start with provided attributes
|
||||
resource_attributes: dict[str, Any] = dict(attributes)
|
||||
|
||||
# Set service name
|
||||
if service_name is None:
|
||||
service_name = os.getenv("OTEL_SERVICE_NAME", "agent_framework")
|
||||
resource_attributes[OtelAttr.SERVICE_NAME] = service_name
|
||||
resource_attributes[service_attributes.SERVICE_NAME] = service_name
|
||||
|
||||
# Set service version
|
||||
if service_version is None:
|
||||
service_version = os.getenv("OTEL_SERVICE_VERSION", version_info)
|
||||
resource_attributes[OtelAttr.SERVICE_VERSION] = service_version
|
||||
resource_attributes[service_attributes.SERVICE_VERSION] = service_version
|
||||
|
||||
# Parse OTEL_RESOURCE_ATTRIBUTES environment variable
|
||||
# Format: key1=value1,key2=value2
|
||||
if resource_attrs_env := os.getenv("OTEL_RESOURCE_ATTRIBUTES"):
|
||||
resource_attributes.update(_parse_headers(resource_attrs_env))
|
||||
return Resource.create(resource_attributes)
|
||||
@@ -604,15 +602,10 @@ def create_resource(
|
||||
|
||||
def create_metric_views() -> list[View]:
|
||||
"""Create the default OpenTelemetry metric views for Agent Framework."""
|
||||
try:
|
||||
from opentelemetry.sdk.metrics.view import DropAggregation, View
|
||||
except ModuleNotFoundError as ex:
|
||||
raise ModuleNotFoundError(
|
||||
"`opentelemetry-sdk` is required to use `create_metric_views()`. "
|
||||
"Please install `opentelemetry-sdk` and update your dependencies."
|
||||
) from ex
|
||||
from opentelemetry.sdk.metrics.view import DropAggregation, View
|
||||
|
||||
return [
|
||||
# Dropping all enable_instrumentation names except for those starting with "agent_framework"
|
||||
View(instrument_name="agent_framework*"),
|
||||
View(instrument_name="gen_ai*"),
|
||||
View(instrument_name="*", aggregation=DropAggregation()),
|
||||
@@ -666,7 +659,7 @@ class ObservabilitySettings:
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
"""Initialize the settings."""
|
||||
"""Initialize the settings and create the resource."""
|
||||
env_file_path = kwargs.pop("env_file_path", None)
|
||||
env_file_encoding = kwargs.pop("env_file_encoding", None)
|
||||
data = load_settings(
|
||||
@@ -681,6 +674,10 @@ class ObservabilitySettings:
|
||||
self.vs_code_extension_port: int | None = data.get("vs_code_extension_port")
|
||||
self.env_file_path = env_file_path
|
||||
self.env_file_encoding = env_file_encoding
|
||||
self._resource = create_resource(
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
)
|
||||
self._executed_setup = False
|
||||
|
||||
@property
|
||||
@@ -765,27 +762,17 @@ class ObservabilitySettings:
|
||||
exporters: A list of exporters for logs, metrics and/or spans.
|
||||
views: Optional list of OpenTelemetry views for metrics. Default is empty list.
|
||||
"""
|
||||
try:
|
||||
from opentelemetry._logs import set_logger_provider
|
||||
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
|
||||
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, LogRecordExporter
|
||||
from opentelemetry.sdk.metrics import MeterProvider
|
||||
from opentelemetry.sdk.metrics.export import MetricExporter, PeriodicExportingMetricReader
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter
|
||||
except ModuleNotFoundError as ex:
|
||||
raise ModuleNotFoundError(
|
||||
"`opentelemetry-sdk` is required to use `configure_otel_providers()`. "
|
||||
"Please install `opentelemetry-sdk` and update your dependencies."
|
||||
) from ex
|
||||
from opentelemetry._logs import set_logger_provider
|
||||
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
|
||||
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, LogRecordExporter
|
||||
from opentelemetry.sdk.metrics import MeterProvider
|
||||
from opentelemetry.sdk.metrics.export import MetricExporter, PeriodicExportingMetricReader
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter
|
||||
|
||||
span_exporters: list[SpanExporter] = []
|
||||
log_exporters: list[LogRecordExporter] = []
|
||||
metric_exporters: list[MetricExporter] = []
|
||||
resource = create_resource(
|
||||
env_file_path=self.env_file_path,
|
||||
env_file_encoding=self.env_file_encoding,
|
||||
)
|
||||
for exp in exporters:
|
||||
if isinstance(exp, SpanExporter):
|
||||
span_exporters.append(exp)
|
||||
@@ -796,14 +783,14 @@ class ObservabilitySettings:
|
||||
|
||||
# Tracing
|
||||
if span_exporters:
|
||||
tracer_provider = TracerProvider(resource=resource)
|
||||
tracer_provider = TracerProvider(resource=self._resource)
|
||||
trace.set_tracer_provider(tracer_provider)
|
||||
for exporter in span_exporters:
|
||||
tracer_provider.add_span_processor(BatchSpanProcessor(exporter))
|
||||
|
||||
# Logging
|
||||
if log_exporters:
|
||||
logger_provider = LoggerProvider(resource=resource)
|
||||
logger_provider = LoggerProvider(resource=self._resource)
|
||||
for log_exporter in log_exporters:
|
||||
logger_provider.add_log_record_processor(BatchLogRecordProcessor(log_exporter))
|
||||
# Attach a handler with the provider to the root logger
|
||||
@@ -818,7 +805,7 @@ class ObservabilitySettings:
|
||||
PeriodicExportingMetricReader(exporter, export_interval_millis=5000)
|
||||
for exporter in metric_exporters
|
||||
],
|
||||
resource=resource,
|
||||
resource=self._resource,
|
||||
views=views or [],
|
||||
)
|
||||
metrics.set_meter_provider(meter_provider)
|
||||
@@ -1119,6 +1106,7 @@ def configure_otel_providers(
|
||||
OBSERVABILITY_SETTINGS.vs_code_extension_port = updated_settings.vs_code_extension_port
|
||||
OBSERVABILITY_SETTINGS.env_file_path = updated_settings.env_file_path
|
||||
OBSERVABILITY_SETTINGS.env_file_encoding = updated_settings.env_file_encoding
|
||||
OBSERVABILITY_SETTINGS._resource = updated_settings._resource # type: ignore[reportPrivateUsage]
|
||||
OBSERVABILITY_SETTINGS._executed_setup = False # type: ignore[reportPrivateUsage]
|
||||
else:
|
||||
# Re-read settings from current environment in case env vars were set
|
||||
@@ -1135,6 +1123,7 @@ def configure_otel_providers(
|
||||
OBSERVABILITY_SETTINGS.vs_code_extension_port = (
|
||||
vs_code_extension_port if vs_code_extension_port is not None else _read_int_env("VS_CODE_EXTENSION_PORT")
|
||||
)
|
||||
OBSERVABILITY_SETTINGS._resource = create_resource() # type: ignore[reportPrivateUsage]
|
||||
OBSERVABILITY_SETTINGS._executed_setup = False # type: ignore[reportPrivateUsage]
|
||||
|
||||
OBSERVABILITY_SETTINGS._configure( # type: ignore[reportPrivateUsage]
|
||||
@@ -1148,7 +1137,7 @@ def configure_otel_providers(
|
||||
|
||||
def _get_duration_histogram() -> metrics.Histogram:
|
||||
return get_meter().create_histogram(
|
||||
name=OtelAttr.LLM_OPERATION_DURATION,
|
||||
name=Meters.LLM_OPERATION_DURATION,
|
||||
unit=OtelAttr.DURATION_UNIT,
|
||||
description="Captures the duration of operations of function-invoking chat clients",
|
||||
explicit_bucket_boundaries_advisory=OPERATION_DURATION_BUCKET_BOUNDARIES,
|
||||
@@ -1157,7 +1146,7 @@ def _get_duration_histogram() -> metrics.Histogram:
|
||||
|
||||
def _get_token_usage_histogram() -> metrics.Histogram:
|
||||
return get_meter().create_histogram(
|
||||
name=OtelAttr.LLM_TOKEN_USAGE,
|
||||
name=Meters.LLM_TOKEN_USAGE,
|
||||
unit=OtelAttr.T_UNIT,
|
||||
description="Captures the token usage of chat clients",
|
||||
explicit_bucket_boundaries_advisory=TOKEN_USAGE_BUCKET_BOUNDARIES,
|
||||
|
||||
@@ -23,20 +23,28 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
# utilities
|
||||
"typing-extensions>=4.15.0,<5",
|
||||
"pydantic>=2,<3",
|
||||
"python-dotenv>=1,<2",
|
||||
# telemetry
|
||||
"opentelemetry-api>=1.39.0,<2",
|
||||
"opentelemetry-sdk>=1.39.0,<2",
|
||||
"opentelemetry-semantic-conventions-ai>=0.4.13,<0.4.14",
|
||||
# connectors and functions
|
||||
"openai>=1.99.0,<3",
|
||||
"azure-identity>=1,<2",
|
||||
"azure-ai-projects>=2.0.0,<3.0",
|
||||
"mcp[ws]>=1.24.0,<2",
|
||||
"packaging>=24.1,<25",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
all = [
|
||||
"mcp>=1.24.0,<2",
|
||||
"agent-framework-a2a",
|
||||
"agent-framework-ag-ui",
|
||||
"agent-framework-azure-ai-search",
|
||||
"agent-framework-anthropic",
|
||||
"agent-framework-openai",
|
||||
"agent-framework-claude",
|
||||
"agent-framework-azure-ai",
|
||||
"agent-framework-azurefunctions",
|
||||
|
||||
@@ -67,7 +67,7 @@ def span_exporter(monkeypatch, enable_instrumentation: bool, enable_sensitive_da
|
||||
if enable_instrumentation or enable_sensitive_data:
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
|
||||
tracer_provider = TracerProvider(resource=observability.create_resource())
|
||||
tracer_provider = TracerProvider(resource=observability_settings._resource)
|
||||
trace.set_tracer_provider(tracer_provider)
|
||||
|
||||
monkeypatch.setattr(observability, "OBSERVABILITY_SETTINGS", observability_settings, raising=False) # type: ignore
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
# type: ignore[reportPrivateUsage]
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from contextlib import _AsyncGeneratorContextManager # type: ignore
|
||||
@@ -24,9 +23,13 @@ from agent_framework import (
|
||||
)
|
||||
from agent_framework._mcp import (
|
||||
MCPTool,
|
||||
_build_prefixed_mcp_name,
|
||||
_get_input_model_from_mcp_prompt,
|
||||
_normalize_mcp_name,
|
||||
_parse_content_from_mcp,
|
||||
_parse_message_from_mcp,
|
||||
_parse_tool_result_from_mcp,
|
||||
_prepare_content_for_mcp,
|
||||
_prepare_message_for_mcp,
|
||||
logger,
|
||||
)
|
||||
from agent_framework._middleware import FunctionMiddlewarePipeline
|
||||
@@ -47,9 +50,6 @@ def _mcp_result_to_text(result: str | list[Content]) -> str:
|
||||
return text or str(result)
|
||||
|
||||
|
||||
_HELPER_MCP_TOOL = MCPTool(name="helper")
|
||||
|
||||
|
||||
# Helper function tests
|
||||
def test_normalize_mcp_name():
|
||||
"""Test MCP name normalization."""
|
||||
@@ -61,10 +61,6 @@ def test_normalize_mcp_name():
|
||||
assert _normalize_mcp_name("name/with\\slashes") == "name-with-slashes"
|
||||
|
||||
|
||||
def test_build_prefixed_mcp_name_ignores_empty_normalized_prefix() -> None:
|
||||
assert _build_prefixed_mcp_name("search", "---") == "search"
|
||||
|
||||
|
||||
def test_mcp_transport_subclasses_accept_tool_name_prefix() -> None:
|
||||
assert MCPStdioTool(name="stdio", command="python", tool_name_prefix="stdio").tool_name_prefix == "stdio"
|
||||
assert (
|
||||
@@ -143,7 +139,7 @@ async def test_load_prompts_with_tool_name_prefix() -> None:
|
||||
def test_mcp_prompt_message_to_ai_content():
|
||||
"""Test conversion from MCP prompt message to AI content."""
|
||||
mcp_message = types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hello, world!"))
|
||||
ai_content = _HELPER_MCP_TOOL._parse_message_from_mcp(mcp_message)
|
||||
ai_content = _parse_message_from_mcp(mcp_message)
|
||||
|
||||
assert isinstance(ai_content, Message)
|
||||
assert ai_content.role == "user"
|
||||
@@ -153,55 +149,6 @@ def test_mcp_prompt_message_to_ai_content():
|
||||
assert ai_content.raw_representation == mcp_message
|
||||
|
||||
|
||||
def test_mcp_tool_str_and_parse_prompt_result_rich_content() -> None:
|
||||
tool = MCPTool(name="helper", description="Helper MCP tool")
|
||||
prompt_result = types.GetPromptResult(
|
||||
messages=[
|
||||
types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hello")),
|
||||
types.PromptMessage(
|
||||
role="assistant",
|
||||
content=types.ImageContent(type="image", data="eHl6", mimeType="image/png"),
|
||||
),
|
||||
types.PromptMessage(
|
||||
role="assistant",
|
||||
content=types.AudioContent(type="audio", data="YXVkaW8=", mimeType="audio/wav"),
|
||||
),
|
||||
types.PromptMessage(
|
||||
role="assistant",
|
||||
content=types.EmbeddedResource(
|
||||
type="resource",
|
||||
resource=types.TextResourceContents(
|
||||
uri=AnyUrl("file://prompt.txt"),
|
||||
mimeType="text/plain",
|
||||
text="Embedded prompt",
|
||||
),
|
||||
),
|
||||
),
|
||||
types.PromptMessage(
|
||||
role="assistant",
|
||||
content=types.EmbeddedResource(
|
||||
type="resource",
|
||||
resource=types.BlobResourceContents(
|
||||
uri=AnyUrl("file://prompt.bin"),
|
||||
mimeType="application/pdf",
|
||||
blob="ZGF0YQ==",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
result = tool._parse_prompt_result_from_mcp(prompt_result)
|
||||
parsed = json.loads(result)
|
||||
|
||||
assert str(tool) == "MCPTool(name=helper, description=Helper MCP tool)"
|
||||
assert parsed[0] == "Hello"
|
||||
assert json.loads(parsed[1]) == {"type": "image", "data": "eHl6", "mimeType": "image/png"}
|
||||
assert json.loads(parsed[2]) == {"type": "audio", "data": "YXVkaW8=", "mimeType": "audio/wav"}
|
||||
assert parsed[3] == "Embedded prompt"
|
||||
assert json.loads(parsed[4]) == {"type": "blob", "data": "ZGF0YQ==", "mimeType": "application/pdf"}
|
||||
|
||||
|
||||
def test_parse_tool_result_from_mcp():
|
||||
"""Test conversion from MCP tool result with images preserves original order."""
|
||||
mcp_result = types.CallToolResult(
|
||||
@@ -212,7 +159,7 @@ def test_parse_tool_result_from_mcp():
|
||||
types.ImageContent(type="image", data="YWJj", mimeType="image/webp"),
|
||||
]
|
||||
)
|
||||
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
|
||||
result = _parse_tool_result_from_mcp(mcp_result)
|
||||
|
||||
# Results with images return a list of Content objects in original order
|
||||
assert isinstance(result, list)
|
||||
@@ -233,7 +180,7 @@ def test_parse_tool_result_from_mcp():
|
||||
def test_parse_tool_result_from_mcp_single_text():
|
||||
"""Test conversion from MCP tool result with a single text item."""
|
||||
mcp_result = types.CallToolResult(content=[types.TextContent(type="text", text="Simple result")])
|
||||
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
|
||||
result = _parse_tool_result_from_mcp(mcp_result)
|
||||
|
||||
# Single text item returns list with one text Content
|
||||
assert isinstance(result, list)
|
||||
@@ -249,7 +196,7 @@ def test_parse_tool_result_from_mcp_meta_not_in_string():
|
||||
_meta={"isError": True, "errorCode": "TOOL_ERROR"},
|
||||
)
|
||||
|
||||
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
|
||||
result = _parse_tool_result_from_mcp(mcp_result)
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert result[0].text == "Error occurred"
|
||||
@@ -258,7 +205,7 @@ def test_parse_tool_result_from_mcp_meta_not_in_string():
|
||||
def test_parse_tool_result_from_mcp_empty_content():
|
||||
"""Test that empty MCP content normalizes to JSON null text content."""
|
||||
mcp_result = types.CallToolResult(content=[])
|
||||
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
|
||||
result = _parse_tool_result_from_mcp(mcp_result)
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert result[0].type == "text"
|
||||
@@ -275,7 +222,7 @@ def test_parse_tool_result_from_mcp_audio_content():
|
||||
types.AudioContent(type="audio", data="YXVkaW8=", mimeType="audio/wav"),
|
||||
]
|
||||
)
|
||||
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
|
||||
result = _parse_tool_result_from_mcp(mcp_result)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
@@ -298,7 +245,7 @@ def test_parse_tool_result_from_mcp_blob_plain_base64():
|
||||
),
|
||||
]
|
||||
)
|
||||
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
|
||||
result = _parse_tool_result_from_mcp(mcp_result)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
@@ -307,39 +254,10 @@ def test_parse_tool_result_from_mcp_blob_plain_base64():
|
||||
assert "dGVzdCBkYXRh" in result[0].uri
|
||||
|
||||
|
||||
def test_parse_tool_result_from_mcp_resource_link_text_resource_and_unknown():
|
||||
"""Test additional MCP tool result variants."""
|
||||
mcp_result = types.CallToolResult(
|
||||
content=[
|
||||
types.ResourceLink(
|
||||
type="resource_link",
|
||||
uri=AnyUrl("https://example.com/resource"),
|
||||
name="resource",
|
||||
mimeType="application/json",
|
||||
),
|
||||
types.EmbeddedResource(
|
||||
type="resource",
|
||||
resource=types.TextResourceContents(
|
||||
uri=AnyUrl("file://prompt.txt"),
|
||||
mimeType="text/plain",
|
||||
text="Embedded result",
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
|
||||
|
||||
assert result[0].type == "uri"
|
||||
assert result[0].uri == "https://example.com/resource"
|
||||
assert result[1].type == "text"
|
||||
assert result[1].text == "Embedded result"
|
||||
|
||||
|
||||
def test_mcp_content_types_to_ai_content_text():
|
||||
"""Test conversion of MCP text content to AI content."""
|
||||
mcp_content = types.TextContent(type="text", text="Sample text")
|
||||
ai_content = _HELPER_MCP_TOOL._parse_content_from_mcp(mcp_content)[0]
|
||||
ai_content = _parse_content_from_mcp(mcp_content)[0]
|
||||
|
||||
assert ai_content.type == "text"
|
||||
assert ai_content.text == "Sample text"
|
||||
@@ -350,7 +268,7 @@ def test_mcp_content_types_to_ai_content_image():
|
||||
"""Test conversion of MCP image content to AI content."""
|
||||
# MCP can send data as base64 string or as bytes
|
||||
mcp_content = types.ImageContent(type="image", data="YWJj", mimeType="image/jpeg") # base64 for b"abc"
|
||||
ai_content = _HELPER_MCP_TOOL._parse_content_from_mcp(mcp_content)[0]
|
||||
ai_content = _parse_content_from_mcp(mcp_content)[0]
|
||||
|
||||
assert ai_content.type == "data"
|
||||
assert ai_content.uri == "data:image/jpeg;base64,YWJj"
|
||||
@@ -362,7 +280,7 @@ def test_mcp_content_types_to_ai_content_audio():
|
||||
"""Test conversion of MCP audio content to AI content."""
|
||||
# Use properly padded base64
|
||||
mcp_content = types.AudioContent(type="audio", data="ZGVm", mimeType="audio/wav") # base64 for b"def"
|
||||
ai_content = _HELPER_MCP_TOOL._parse_content_from_mcp(mcp_content)[0]
|
||||
ai_content = _parse_content_from_mcp(mcp_content)[0]
|
||||
|
||||
assert ai_content.type == "data"
|
||||
assert ai_content.uri == "data:audio/wav;base64,ZGVm"
|
||||
@@ -378,7 +296,7 @@ def test_mcp_content_types_to_ai_content_resource_link():
|
||||
name="test_resource",
|
||||
mimeType="application/json",
|
||||
)
|
||||
ai_content = _HELPER_MCP_TOOL._parse_content_from_mcp(mcp_content)[0]
|
||||
ai_content = _parse_content_from_mcp(mcp_content)[0]
|
||||
|
||||
assert ai_content.type == "uri"
|
||||
assert ai_content.uri == "https://example.com/resource"
|
||||
@@ -394,7 +312,7 @@ def test_mcp_content_types_to_ai_content_embedded_resource_text():
|
||||
text="Embedded text content",
|
||||
)
|
||||
mcp_content = types.EmbeddedResource(type="resource", resource=text_resource)
|
||||
ai_content = _HELPER_MCP_TOOL._parse_content_from_mcp(mcp_content)[0]
|
||||
ai_content = _parse_content_from_mcp(mcp_content)[0]
|
||||
|
||||
assert ai_content.type == "text"
|
||||
assert ai_content.text == "Embedded text content"
|
||||
@@ -410,7 +328,7 @@ def test_mcp_content_types_to_ai_content_embedded_resource_blob():
|
||||
blob="data:application/octet-stream;base64,dGVzdCBkYXRh",
|
||||
)
|
||||
mcp_content = types.EmbeddedResource(type="resource", resource=blob_resource)
|
||||
ai_content = _HELPER_MCP_TOOL._parse_content_from_mcp(mcp_content)[0]
|
||||
ai_content = _parse_content_from_mcp(mcp_content)[0]
|
||||
|
||||
assert ai_content.type == "data"
|
||||
assert ai_content.uri == "data:application/octet-stream;base64,dGVzdCBkYXRh"
|
||||
@@ -418,33 +336,10 @@ def test_mcp_content_types_to_ai_content_embedded_resource_blob():
|
||||
assert ai_content.raw_representation == mcp_content
|
||||
|
||||
|
||||
def test_mcp_content_types_to_ai_content_tool_use_and_tool_result():
|
||||
"""Test conversion of MCP tool use/result content to AI function call/result content."""
|
||||
tool_use_content = types.ToolUseContent(type="tool_use", id="call-1", name="calculator", input={"x": 1})
|
||||
tool_result_content = types.ToolResultContent(
|
||||
type="tool_result",
|
||||
toolUseId="call-1",
|
||||
content=[types.TextContent(type="text", text="done")],
|
||||
isError=True,
|
||||
)
|
||||
|
||||
function_call = _HELPER_MCP_TOOL._parse_content_from_mcp(tool_use_content)[0]
|
||||
function_result = _HELPER_MCP_TOOL._parse_content_from_mcp(tool_result_content)[0]
|
||||
|
||||
assert function_call.type == "function_call"
|
||||
assert function_call.call_id == "call-1"
|
||||
assert function_call.name == "calculator"
|
||||
assert function_call.arguments == {"x": 1}
|
||||
assert function_result.type == "function_result"
|
||||
assert function_result.call_id == "call-1"
|
||||
assert function_result.result == "done"
|
||||
assert function_result.exception == ""
|
||||
|
||||
|
||||
def test_ai_content_to_mcp_content_types_text():
|
||||
"""Test conversion of AI text content to MCP content."""
|
||||
ai_content = Content.from_text(text="Sample text")
|
||||
mcp_content = _HELPER_MCP_TOOL._prepare_content_for_mcp(ai_content)
|
||||
mcp_content = _prepare_content_for_mcp(ai_content)
|
||||
|
||||
assert isinstance(mcp_content, types.TextContent)
|
||||
assert mcp_content.type == "text"
|
||||
@@ -454,7 +349,7 @@ def test_ai_content_to_mcp_content_types_text():
|
||||
def test_ai_content_to_mcp_content_types_data_image():
|
||||
"""Test conversion of AI data content to MCP content."""
|
||||
ai_content = Content.from_uri(uri="data:image/png;base64,xyz", media_type="image/png")
|
||||
mcp_content = _HELPER_MCP_TOOL._prepare_content_for_mcp(ai_content)
|
||||
mcp_content = _prepare_content_for_mcp(ai_content)
|
||||
|
||||
assert isinstance(mcp_content, types.ImageContent)
|
||||
assert mcp_content.type == "image"
|
||||
@@ -465,7 +360,7 @@ def test_ai_content_to_mcp_content_types_data_image():
|
||||
def test_ai_content_to_mcp_content_types_data_audio():
|
||||
"""Test conversion of AI data content to MCP content."""
|
||||
ai_content = Content.from_uri(uri="data:audio/mpeg;base64,xyz", media_type="audio/mpeg")
|
||||
mcp_content = _HELPER_MCP_TOOL._prepare_content_for_mcp(ai_content)
|
||||
mcp_content = _prepare_content_for_mcp(ai_content)
|
||||
|
||||
assert isinstance(mcp_content, types.AudioContent)
|
||||
assert mcp_content.type == "audio"
|
||||
@@ -479,7 +374,7 @@ def test_ai_content_to_mcp_content_types_data_binary():
|
||||
uri="data:application/octet-stream;base64,xyz",
|
||||
media_type="application/octet-stream",
|
||||
)
|
||||
mcp_content = _HELPER_MCP_TOOL._prepare_content_for_mcp(ai_content)
|
||||
mcp_content = _prepare_content_for_mcp(ai_content)
|
||||
|
||||
assert isinstance(mcp_content, types.EmbeddedResource)
|
||||
assert mcp_content.type == "resource"
|
||||
@@ -490,7 +385,7 @@ def test_ai_content_to_mcp_content_types_data_binary():
|
||||
def test_ai_content_to_mcp_content_types_uri():
|
||||
"""Test conversion of AI URI content to MCP content."""
|
||||
ai_content = Content.from_uri(uri="https://example.com/resource", media_type="application/json")
|
||||
mcp_content = _HELPER_MCP_TOOL._prepare_content_for_mcp(ai_content)
|
||||
mcp_content = _prepare_content_for_mcp(ai_content)
|
||||
|
||||
assert isinstance(mcp_content, types.ResourceLink)
|
||||
assert mcp_content.type == "resource_link"
|
||||
@@ -506,24 +401,12 @@ def test_prepare_message_for_mcp():
|
||||
Content.from_uri(uri="data:image/png;base64,xyz", media_type="image/png"),
|
||||
],
|
||||
)
|
||||
mcp_contents = _HELPER_MCP_TOOL._prepare_message_for_mcp(message)
|
||||
mcp_contents = _prepare_message_for_mcp(message)
|
||||
assert len(mcp_contents) == 2
|
||||
assert isinstance(mcp_contents[0], types.TextContent)
|
||||
assert isinstance(mcp_contents[1], types.ImageContent)
|
||||
|
||||
|
||||
def test_prepare_message_for_mcp_skips_unsupported_content() -> None:
|
||||
unsupported = Content(type="annotations", text="ignored")
|
||||
|
||||
assert _HELPER_MCP_TOOL._prepare_content_for_mcp(unsupported) is None
|
||||
|
||||
mcp_contents = _HELPER_MCP_TOOL._prepare_message_for_mcp(
|
||||
Message(role="user", contents=[Content.from_text("kept"), unsupported])
|
||||
)
|
||||
assert len(mcp_contents) == 1
|
||||
assert isinstance(mcp_contents[0], types.TextContent)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"test_id,input_schema",
|
||||
[
|
||||
@@ -1404,18 +1287,6 @@ async def test_mcp_tool_approval_mode(approval_mode, expected_approvals):
|
||||
assert func.approval_mode == expected_approvals[func.name]
|
||||
|
||||
|
||||
def test_mcp_tool_approval_mode_returns_none_for_unmatched_names() -> None:
|
||||
tool = MCPTool(
|
||||
name="test_tool",
|
||||
approval_mode={
|
||||
"always_require_approval": ["tool_one"],
|
||||
"never_require_approval": ["tool_two"],
|
||||
},
|
||||
)
|
||||
|
||||
assert tool._determine_approval_mode("tool_three") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"allowed_tools,expected_count,expected_names",
|
||||
[
|
||||
@@ -1747,46 +1618,6 @@ async def test_mcp_tool_sampling_callback_no_valid_content():
|
||||
assert "Failed to get right content types from the response." in result.message
|
||||
|
||||
|
||||
async def test_mcp_tool_sampling_callback_no_response_and_successful_message_creation():
|
||||
"""Test sampling callback when the chat client returns no response and then valid content."""
|
||||
tool = MCPStdioTool(name="test_tool", command="python")
|
||||
tool.client = AsyncMock()
|
||||
|
||||
params = Mock()
|
||||
params.messages = [types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hi"))]
|
||||
params.temperature = None
|
||||
params.maxTokens = None
|
||||
params.stopSequences = None
|
||||
|
||||
tool.client.get_response.return_value = None
|
||||
no_response = await tool.sampling_callback(Mock(), params)
|
||||
|
||||
assert isinstance(no_response, types.ErrorData)
|
||||
assert no_response.message == "Failed to get chat message content."
|
||||
|
||||
tool.client.get_response.return_value = Mock(
|
||||
messages=[Message(role="assistant", contents=[Content.from_text("Hello")])],
|
||||
model_id="test-model",
|
||||
)
|
||||
|
||||
success = await tool.sampling_callback(Mock(), params)
|
||||
|
||||
assert isinstance(success, types.CreateMessageResult)
|
||||
assert success.role == "assistant"
|
||||
assert success.model == "test-model"
|
||||
assert isinstance(success.content, types.TextContent)
|
||||
assert success.content.text == "Hello"
|
||||
|
||||
|
||||
async def test_mcp_tool_logging_callback_logs_at_requested_level() -> None:
|
||||
tool = MCPStdioTool(name="test_tool", command="python")
|
||||
|
||||
with patch.object(logger, "log") as mock_log:
|
||||
await tool.logging_callback(types.LoggingMessageNotificationParams(level="warning", data="be careful"))
|
||||
|
||||
mock_log.assert_called_once_with(logging.WARNING, "be careful")
|
||||
|
||||
|
||||
# Test error handling in connect() method
|
||||
|
||||
|
||||
@@ -1802,7 +1633,7 @@ async def test_connect_session_creation_failure():
|
||||
tool.get_mcp_client = Mock(return_value=mock_context_manager)
|
||||
|
||||
# Mock ClientSession to raise an exception
|
||||
with patch("mcp.client.session.ClientSession") as mock_session_class:
|
||||
with patch("agent_framework._mcp.ClientSession") as mock_session_class:
|
||||
mock_session_class.side_effect = RuntimeError("Session creation failed")
|
||||
|
||||
with pytest.raises(ToolException) as exc_info:
|
||||
@@ -1827,7 +1658,7 @@ async def test_connect_initialization_failure_http_no_command():
|
||||
mock_session = Mock()
|
||||
mock_session.initialize = AsyncMock(side_effect=ConnectionError("Server not ready"))
|
||||
|
||||
with patch("mcp.client.session.ClientSession") as mock_session_class:
|
||||
with patch("agent_framework._mcp.ClientSession") as mock_session_class:
|
||||
mock_session_class.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session_class.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
@@ -1856,18 +1687,6 @@ async def test_connect_cleanup_on_transport_failure():
|
||||
tool._exit_stack.aclose.assert_called_once()
|
||||
|
||||
|
||||
async def test_connect_cleanup_on_transport_failure_http_uses_generic_message():
|
||||
"""Test HTTP transport failures use the generic connection message when no command exists."""
|
||||
tool = MCPStreamableHTTPTool(name="test", url="https://example.com/mcp")
|
||||
tool._exit_stack.aclose = AsyncMock()
|
||||
tool.get_mcp_client = Mock(side_effect=RuntimeError("Transport failed"))
|
||||
|
||||
with pytest.raises(ToolException, match="Failed to connect to MCP server: Transport failed"):
|
||||
await tool.connect()
|
||||
|
||||
tool._exit_stack.aclose.assert_called_once()
|
||||
|
||||
|
||||
async def test_connect_cleanup_on_initialization_failure():
|
||||
"""Test that _exit_stack.aclose() is called when initialization fails."""
|
||||
tool = MCPStdioTool(name="test", command="test-command")
|
||||
@@ -1886,7 +1705,7 @@ async def test_connect_cleanup_on_initialization_failure():
|
||||
mock_session = Mock()
|
||||
mock_session.initialize = AsyncMock(side_effect=RuntimeError("Init failed"))
|
||||
|
||||
with patch("mcp.client.session.ClientSession") as mock_session_class:
|
||||
with patch("agent_framework._mcp.ClientSession") as mock_session_class:
|
||||
mock_session_class.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session_class.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
@@ -1903,20 +1722,18 @@ def test_mcp_stdio_tool_get_mcp_client_with_env_and_kwargs():
|
||||
tool = MCPStdioTool(
|
||||
name="test",
|
||||
command="test-command",
|
||||
encoding="utf-16",
|
||||
env=env_vars,
|
||||
custom_param="value1",
|
||||
another_param=42,
|
||||
)
|
||||
|
||||
with patch("mcp.client.stdio.stdio_client"), patch("mcp.client.stdio.StdioServerParameters") as mock_params:
|
||||
with patch("agent_framework._mcp.stdio_client"), patch("agent_framework._mcp.StdioServerParameters") as mock_params:
|
||||
tool.get_mcp_client()
|
||||
|
||||
# Verify all parameters including custom kwargs were passed
|
||||
mock_params.assert_called_once_with(
|
||||
command="test-command",
|
||||
args=[],
|
||||
encoding="utf-16",
|
||||
env=env_vars,
|
||||
custom_param="value1",
|
||||
another_param=42,
|
||||
@@ -1931,7 +1748,7 @@ def test_mcp_streamable_http_tool_get_mcp_client_all_params():
|
||||
terminate_on_close=True,
|
||||
)
|
||||
|
||||
with patch("mcp.client.streamable_http.streamable_http_client") as mock_http_client:
|
||||
with patch("agent_framework._mcp.streamable_http_client") as mock_http_client:
|
||||
tool.get_mcp_client()
|
||||
|
||||
# Verify streamable_http_client was called with None for http_client
|
||||
@@ -1953,7 +1770,7 @@ def test_mcp_websocket_tool_get_mcp_client_with_kwargs():
|
||||
compression="deflate",
|
||||
)
|
||||
|
||||
with patch("mcp.client.websocket.websocket_client") as mock_ws_client:
|
||||
with patch("agent_framework._mcp.websocket_client") as mock_ws_client:
|
||||
tool.get_mcp_client()
|
||||
|
||||
# Verify all kwargs were passed
|
||||
@@ -2111,8 +1928,8 @@ async def test_mcp_streamable_http_tool_httpx_client_cleanup():
|
||||
|
||||
# Mock the streamable_http_client to avoid actual connections
|
||||
with (
|
||||
patch("mcp.client.streamable_http.streamable_http_client") as mock_client,
|
||||
patch("mcp.client.session.ClientSession") as mock_session_class,
|
||||
patch("agent_framework._mcp.streamable_http_client") as mock_client,
|
||||
patch("agent_framework._mcp.ClientSession") as mock_session_class,
|
||||
):
|
||||
# Setup mock context manager for streamable_http_client
|
||||
mock_transport = (Mock(), Mock())
|
||||
@@ -2807,80 +2624,6 @@ async def test_mcp_tool_get_prompt_reconnection_on_closed_resource_error():
|
||||
assert "failed to reconnect" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
async def test_mcp_tool_call_tool_requires_loaded_tools() -> None:
|
||||
tool = MCPTool(name="test_tool", load_tools=False)
|
||||
|
||||
with pytest.raises(ToolExecutionException, match="Tools are not loaded"):
|
||||
await tool.call_tool("remote_tool")
|
||||
|
||||
|
||||
async def test_mcp_tool_get_prompt_requires_loaded_prompts() -> None:
|
||||
tool = MCPTool(name="test_tool", load_prompts=False)
|
||||
|
||||
with pytest.raises(ToolExecutionException, match="Prompts are not loaded"):
|
||||
await tool.get_prompt("remote_prompt")
|
||||
|
||||
|
||||
async def test_mcp_tool_call_tool_raises_after_reconnection_still_fails() -> None:
|
||||
from anyio.streams.memory import ClosedResourceError
|
||||
|
||||
tool = MCPTool(name="test_tool", load_tools=True)
|
||||
tool.session = Mock(call_tool=AsyncMock(side_effect=[ClosedResourceError(), ClosedResourceError()]))
|
||||
|
||||
with (
|
||||
patch.object(tool, "connect", AsyncMock()) as mock_connect,
|
||||
patch.object(logger, "error") as mock_error,
|
||||
pytest.raises(ToolExecutionException, match="connection lost"),
|
||||
):
|
||||
await tool.call_tool("remote_tool")
|
||||
|
||||
mock_connect.assert_awaited_once_with(reset=True)
|
||||
mock_error.assert_called_once()
|
||||
|
||||
|
||||
async def test_mcp_tool_get_prompt_raises_after_reconnection_still_fails() -> None:
|
||||
from anyio.streams.memory import ClosedResourceError
|
||||
|
||||
tool = MCPTool(name="test_tool", load_prompts=True)
|
||||
tool.session = Mock(get_prompt=AsyncMock(side_effect=[ClosedResourceError(), ClosedResourceError()]))
|
||||
|
||||
with (
|
||||
patch.object(tool, "connect", AsyncMock()) as mock_connect,
|
||||
patch.object(logger, "error") as mock_error,
|
||||
pytest.raises(ToolExecutionException, match="connection lost"),
|
||||
):
|
||||
await tool.get_prompt("remote_prompt")
|
||||
|
||||
mock_connect.assert_awaited_once_with(reset=True)
|
||||
mock_error.assert_called_once()
|
||||
|
||||
|
||||
async def test_mcp_tool_wraps_unexpected_call_tool_and_get_prompt_errors() -> None:
|
||||
tool = MCPTool(name="test_tool", load_tools=True, load_prompts=True)
|
||||
tool.session = Mock()
|
||||
tool.session.call_tool = AsyncMock(side_effect=RuntimeError("tool boom"))
|
||||
tool.session.get_prompt = AsyncMock(side_effect=RuntimeError("prompt boom"))
|
||||
|
||||
with pytest.raises(ToolExecutionException, match="Failed to call tool 'remote_tool'"):
|
||||
await tool.call_tool("remote_tool")
|
||||
|
||||
with pytest.raises(ToolExecutionException, match="Failed to call prompt 'remote_prompt'"):
|
||||
await tool.get_prompt("remote_prompt")
|
||||
|
||||
|
||||
async def test_mcp_tool_aenter_wraps_unexpected_errors_and_closes() -> None:
|
||||
tool = MCPStdioTool(name="test_tool", command="python")
|
||||
|
||||
with (
|
||||
patch.object(tool, "connect", AsyncMock(side_effect=RuntimeError("boom"))),
|
||||
patch.object(tool, "close", AsyncMock()) as mock_close,
|
||||
pytest.raises(ToolExecutionException, match="Failed to enter context manager"),
|
||||
):
|
||||
await tool.__aenter__()
|
||||
|
||||
mock_close.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_mcp_tool_close_cleans_up_in_original_task(caplog):
|
||||
"""Closing an MCP tool from another task should still unwind contexts in the owner task."""
|
||||
import asyncio
|
||||
@@ -2920,7 +2663,7 @@ async def test_mcp_tool_close_cleans_up_in_original_task(caplog):
|
||||
|
||||
with (
|
||||
patch.object(tool, "get_mcp_client", return_value=transport_context),
|
||||
patch("mcp.client.session.ClientSession", return_value=mock_session_context),
|
||||
patch("agent_framework._mcp.ClientSession", return_value=mock_session_context),
|
||||
):
|
||||
await asyncio.create_task(tool.connect())
|
||||
|
||||
@@ -2978,7 +2721,7 @@ async def test_mcp_tool_connect_reset_cleans_up_in_original_task(caplog):
|
||||
|
||||
with (
|
||||
patch.object(tool, "get_mcp_client", side_effect=transport_contexts),
|
||||
patch("mcp.client.session.ClientSession", side_effect=session_contexts),
|
||||
patch("agent_framework._mcp.ClientSession", side_effect=session_contexts),
|
||||
):
|
||||
await tool.connect()
|
||||
|
||||
@@ -3162,7 +2905,7 @@ async def test_connect_sets_logging_level_when_logger_level_is_set():
|
||||
|
||||
with (
|
||||
patch.object(tool, "get_mcp_client", return_value=mock_context),
|
||||
patch("mcp.client.session.ClientSession", return_value=mock_session_context),
|
||||
patch("agent_framework._mcp.ClientSession", return_value=mock_session_context),
|
||||
patch.object(logger, "level", logging.DEBUG), # Set logger level to DEBUG
|
||||
):
|
||||
await tool.connect()
|
||||
@@ -3199,7 +2942,7 @@ async def test_connect_does_not_set_logging_level_when_logger_level_is_notset():
|
||||
|
||||
with (
|
||||
patch.object(tool, "get_mcp_client", return_value=mock_context),
|
||||
patch("mcp.client.session.ClientSession", return_value=mock_session_context),
|
||||
patch("agent_framework._mcp.ClientSession", return_value=mock_session_context),
|
||||
patch.object(logger, "level", logging.NOTSET), # Set logger level to NOTSET
|
||||
):
|
||||
await tool.connect()
|
||||
@@ -3237,7 +2980,7 @@ async def test_connect_handles_set_logging_level_exception():
|
||||
|
||||
with (
|
||||
patch.object(tool, "get_mcp_client", return_value=mock_context),
|
||||
patch("mcp.client.session.ClientSession", return_value=mock_session_context),
|
||||
patch("agent_framework._mcp.ClientSession", return_value=mock_session_context),
|
||||
patch.object(logger, "level", logging.INFO), # Set logger level to INFO
|
||||
patch.object(logger, "warning") as mock_warning,
|
||||
):
|
||||
@@ -3253,48 +2996,6 @@ async def test_connect_handles_set_logging_level_exception():
|
||||
assert "Failed to set log level" in call_args[0][0]
|
||||
|
||||
|
||||
async def test_connect_reinitializes_existing_session_and_loads_tools_and_prompts() -> None:
|
||||
tool = MCPTool(name="test_tool", load_tools=True, load_prompts=True)
|
||||
tool.is_connected = True
|
||||
tool.session = Mock()
|
||||
tool.session._request_id = 0
|
||||
tool.session.initialize = AsyncMock()
|
||||
|
||||
with (
|
||||
patch.object(tool, "load_tools", AsyncMock()) as mock_load_tools,
|
||||
patch.object(tool, "load_prompts", AsyncMock()) as mock_load_prompts,
|
||||
patch.object(logger, "level", logging.NOTSET),
|
||||
):
|
||||
await tool._connect_on_owner()
|
||||
|
||||
tool.session.initialize.assert_awaited_once()
|
||||
mock_load_tools.assert_awaited_once()
|
||||
mock_load_prompts.assert_awaited_once()
|
||||
assert tool._tools_loaded is True
|
||||
assert tool._prompts_loaded is True
|
||||
|
||||
|
||||
async def test_ensure_connected_reconnects_on_failed_ping() -> None:
|
||||
tool = MCPTool(name="test_tool")
|
||||
tool.session = Mock(send_ping=AsyncMock(side_effect=RuntimeError("closed")))
|
||||
|
||||
with patch.object(tool, "connect", AsyncMock()) as mock_connect:
|
||||
await tool._ensure_connected()
|
||||
|
||||
mock_connect.assert_awaited_once_with(reset=True)
|
||||
|
||||
|
||||
async def test_ensure_connected_wraps_reconnect_failure() -> None:
|
||||
tool = MCPTool(name="test_tool")
|
||||
tool.session = Mock(send_ping=AsyncMock(side_effect=RuntimeError("closed")))
|
||||
|
||||
with (
|
||||
patch.object(tool, "connect", AsyncMock(side_effect=RuntimeError("still closed"))),
|
||||
pytest.raises(ToolExecutionException, match="Failed to establish MCP connection"),
|
||||
):
|
||||
await tool._ensure_connected()
|
||||
|
||||
|
||||
async def test_mcp_tool_filters_framework_kwargs():
|
||||
"""Test that call_tool filters out framework-specific kwargs before calling MCP session.
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import logging
|
||||
from collections.abc import AsyncIterable, Awaitable, MutableSequence, Sequence
|
||||
from typing import Any
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
||||
@@ -1080,8 +1080,7 @@ def test_configure_otel_providers_reads_env_sensitive_data(monkeypatch):
|
||||
# Simulate load_dotenv() setting env var after import
|
||||
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
|
||||
|
||||
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
|
||||
observability.configure_otel_providers()
|
||||
observability.configure_otel_providers()
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
|
||||
|
||||
@@ -1136,8 +1135,7 @@ def test_configure_otel_providers_explicit_param_overrides_env(monkeypatch):
|
||||
importlib.reload(observability)
|
||||
|
||||
# Explicit False should override the env var True
|
||||
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
|
||||
observability.configure_otel_providers(enable_sensitive_data=False)
|
||||
observability.configure_otel_providers(enable_sensitive_data=False)
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
|
||||
|
||||
|
||||
@@ -1198,8 +1196,7 @@ def test_enable_instrumentation_does_not_clobber_console_exporters(monkeypatch):
|
||||
importlib.reload(observability)
|
||||
|
||||
# Set console exporters via configure_otel_providers
|
||||
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
|
||||
observability.configure_otel_providers(enable_console_exporters=True)
|
||||
observability.configure_otel_providers(enable_console_exporters=True)
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
|
||||
|
||||
# Calling enable_instrumentation should not clobber the value
|
||||
@@ -1227,8 +1224,7 @@ def test_enable_instrumentation_with_sensitive_data_does_not_touch_console_expor
|
||||
importlib.reload(observability)
|
||||
|
||||
# Set console exporters via configure_otel_providers
|
||||
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
|
||||
observability.configure_otel_providers(enable_console_exporters=True)
|
||||
observability.configure_otel_providers(enable_console_exporters=True)
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
|
||||
|
||||
# Calling enable_instrumentation with explicit sensitive_data should not clobber console exporters
|
||||
@@ -1279,8 +1275,7 @@ def test_configure_otel_providers_reads_env_console_exporters(monkeypatch):
|
||||
# Simulate load_dotenv() setting env var after import
|
||||
monkeypatch.setenv("ENABLE_CONSOLE_EXPORTERS", "true")
|
||||
|
||||
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
|
||||
observability.configure_otel_providers()
|
||||
observability.configure_otel_providers()
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
|
||||
|
||||
|
||||
@@ -1303,8 +1298,7 @@ def test_configure_otel_providers_explicit_console_exporters_overrides_env(monke
|
||||
importlib.reload(observability)
|
||||
|
||||
# Explicit False should override the env var True
|
||||
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
|
||||
observability.configure_otel_providers(enable_console_exporters=False)
|
||||
observability.configure_otel_providers(enable_console_exporters=False)
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is False
|
||||
|
||||
|
||||
@@ -2011,14 +2005,6 @@ async def test_agent_streaming_observability(span_exporter: InMemorySpanExporter
|
||||
assert len(spans) == 1
|
||||
|
||||
|
||||
def test_agent_middleware_wraps_agent_telemetry() -> None:
|
||||
"""Agent middleware must run outside telemetry so middleware time is excluded from agent latency."""
|
||||
from agent_framework import Agent
|
||||
from agent_framework._middleware import AgentMiddlewareLayer
|
||||
|
||||
assert Agent.__mro__.index(AgentMiddlewareLayer) < Agent.__mro__.index(AgentTelemetryLayer)
|
||||
|
||||
|
||||
# region Test AgentTelemetryLayer error cases
|
||||
|
||||
|
||||
@@ -3063,12 +3049,11 @@ def test_configure_otel_providers_with_env_file_path(monkeypatch, tmp_path):
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text("ENABLE_INSTRUMENTATION=true\n")
|
||||
|
||||
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
|
||||
observability.configure_otel_providers(
|
||||
env_file_path=str(env_file),
|
||||
enable_sensitive_data=True,
|
||||
vs_code_extension_port=None,
|
||||
)
|
||||
observability.configure_otel_providers(
|
||||
env_file_path=str(env_file),
|
||||
enable_sensitive_data=True,
|
||||
vs_code_extension_port=None,
|
||||
)
|
||||
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
|
||||
@@ -3093,12 +3078,11 @@ def test_configure_otel_providers_with_env_file_and_vs_code_port(monkeypatch, tm
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text("ENABLE_INSTRUMENTATION=true\n")
|
||||
|
||||
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
|
||||
observability.configure_otel_providers(
|
||||
env_file_path=str(env_file),
|
||||
env_file_encoding="utf-8",
|
||||
vs_code_extension_port=4317,
|
||||
)
|
||||
observability.configure_otel_providers(
|
||||
env_file_path=str(env_file),
|
||||
env_file_encoding="utf-8",
|
||||
vs_code_extension_port=4317,
|
||||
)
|
||||
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
|
||||
assert observability.OBSERVABILITY_SETTINGS.vs_code_extension_port == 4317
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
import agent_framework
|
||||
import agent_framework.observability as observability
|
||||
from agent_framework import Agent
|
||||
|
||||
|
||||
def _hide_otel_sdk(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import builtins
|
||||
|
||||
real_import = builtins.__import__
|
||||
for module_name in list(sys.modules):
|
||||
if module_name == "opentelemetry.sdk" or module_name.startswith("opentelemetry.sdk."):
|
||||
sys.modules.pop(module_name, None)
|
||||
|
||||
def _import_without_otel_sdk(
|
||||
name: str,
|
||||
globals_: dict[str, object] | None = None,
|
||||
locals_: dict[str, object] | None = None,
|
||||
fromlist: tuple[str, ...] = (),
|
||||
level: int = 0,
|
||||
) -> object:
|
||||
if name == "opentelemetry.sdk" or name.startswith("opentelemetry.sdk."):
|
||||
raise ModuleNotFoundError(f"No module named '{name}'", name=name)
|
||||
return real_import(name, globals_, locals_, fromlist, level)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", _import_without_otel_sdk)
|
||||
|
||||
|
||||
def test_create_resource_requires_otel_sdk(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_hide_otel_sdk(monkeypatch)
|
||||
|
||||
with pytest.raises(ModuleNotFoundError, match="opentelemetry-sdk"):
|
||||
observability.create_resource()
|
||||
|
||||
|
||||
def test_observability_settings_initializes_without_cached_resource(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_hide_otel_sdk(monkeypatch)
|
||||
|
||||
settings = observability.ObservabilitySettings()
|
||||
|
||||
assert not hasattr(settings, "_resource")
|
||||
|
||||
|
||||
def test_configure_otel_providers_requires_otel_sdk(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_hide_otel_sdk(monkeypatch)
|
||||
for key in [
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
"VS_CODE_EXTENSION_PORT",
|
||||
]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
with pytest.raises(ModuleNotFoundError, match="opentelemetry-sdk"):
|
||||
observability.configure_otel_providers()
|
||||
|
||||
|
||||
def test_agent_framework_mcp_exports_remain_importable_without_mcp(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import builtins
|
||||
|
||||
import agent_framework._mcp as mcp_module
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
def _import_without_mcp(
|
||||
name: str,
|
||||
globals_: dict[str, object] | None = None,
|
||||
locals_: dict[str, object] | None = None,
|
||||
fromlist: tuple[str, ...] = (),
|
||||
level: int = 0,
|
||||
) -> object:
|
||||
if name == "mcp" or name.startswith("mcp."):
|
||||
raise ModuleNotFoundError("No module named 'mcp'")
|
||||
return real_import(name, globals_, locals_, fromlist, level)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", _import_without_mcp)
|
||||
assert agent_framework.MCPStdioTool is mcp_module.MCPStdioTool
|
||||
|
||||
with pytest.raises(ModuleNotFoundError, match=r"Please install `mcp`\.$"):
|
||||
agent_framework.MCPStdioTool(name="test", command="python").get_mcp_client()
|
||||
|
||||
|
||||
def test_mcp_streamable_http_tool_requires_mcp(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import builtins
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
def _import_without_mcp(
|
||||
name: str,
|
||||
globals_: dict[str, object] | None = None,
|
||||
locals_: dict[str, object] | None = None,
|
||||
fromlist: tuple[str, ...] = (),
|
||||
level: int = 0,
|
||||
) -> object:
|
||||
if name == "mcp" or name.startswith("mcp."):
|
||||
raise ModuleNotFoundError("No module named 'mcp'")
|
||||
return real_import(name, globals_, locals_, fromlist, level)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", _import_without_mcp)
|
||||
|
||||
with pytest.raises(ModuleNotFoundError, match=r"Please install `mcp`\.$"):
|
||||
agent_framework.MCPStreamableHTTPTool(name="test", url="https://example.com").get_mcp_client()
|
||||
|
||||
|
||||
def test_agent_as_mcp_server_requires_mcp(client, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import builtins
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
def _import_without_mcp(
|
||||
name: str,
|
||||
globals_: dict[str, object] | None = None,
|
||||
locals_: dict[str, object] | None = None,
|
||||
fromlist: tuple[str, ...] = (),
|
||||
level: int = 0,
|
||||
) -> object:
|
||||
if name == "mcp" or name.startswith("mcp."):
|
||||
raise ModuleNotFoundError("No module named 'mcp'")
|
||||
return real_import(name, globals_, locals_, fromlist, level)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", _import_without_mcp)
|
||||
|
||||
agent = Agent(client=client)
|
||||
|
||||
with pytest.raises(ModuleNotFoundError, match=r"Please install `mcp`\.$"):
|
||||
agent.as_mcp_server()
|
||||
|
||||
|
||||
def test_mcp_websocket_tool_requires_ws_support(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import builtins
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
sys.modules.pop("mcp.client.websocket", None)
|
||||
|
||||
def _import_without_websocket_support(
|
||||
name: str,
|
||||
globals_: dict[str, object] | None = None,
|
||||
locals_: dict[str, object] | None = None,
|
||||
fromlist: tuple[str, ...] = (),
|
||||
level: int = 0,
|
||||
) -> object:
|
||||
if name == "mcp.client.websocket":
|
||||
raise ModuleNotFoundError("No module named 'websockets'", name="websockets")
|
||||
return real_import(name, globals_, locals_, fromlist, level)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", _import_without_websocket_support)
|
||||
|
||||
with pytest.raises(ModuleNotFoundError, match=r"mcp\[ws\]"):
|
||||
agent_framework.MCPWebsocketTool(name="test", url="wss://example.com").get_mcp_client()
|
||||
|
||||
|
||||
def test_mcp_websocket_tool_requires_mcp(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import builtins
|
||||
|
||||
real_import = builtins.__import__
|
||||
sys.modules.pop("mcp.client.websocket", None)
|
||||
|
||||
def _import_without_mcp(
|
||||
name: str,
|
||||
globals_: dict[str, object] | None = None,
|
||||
locals_: dict[str, object] | None = None,
|
||||
fromlist: tuple[str, ...] = (),
|
||||
level: int = 0,
|
||||
) -> object:
|
||||
if name == "mcp.client.websocket":
|
||||
raise ModuleNotFoundError("No module named 'mcp.client.websocket'", name="mcp.client.websocket")
|
||||
return real_import(name, globals_, locals_, fromlist, level)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", _import_without_mcp)
|
||||
|
||||
with pytest.raises(ModuleNotFoundError, match=r"agent-framework-core\[mcp\]|mcp\[ws\]"):
|
||||
agent_framework.MCPWebsocketTool(name="test", url="wss://example.com").get_mcp_client()
|
||||
@@ -22,13 +22,12 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0rc5",
|
||||
"openai>=1.99.0,<3",
|
||||
"opentelemetry-sdk>=1.39.0,<2",
|
||||
"fastapi>=0.115.0,<0.133.1",
|
||||
"uvicorn[standard]>=0.30.0,<0.42.0"
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0rc5",
|
||||
"openai>=1.99.0,<3",
|
||||
"fastapi>=0.115.0,<0.133.1",
|
||||
"uvicorn[standard]>=0.30.0,<0.42.0"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
|
||||
@@ -195,8 +195,8 @@ class RawFoundryAgent( # type: ignore[misc]
|
||||
|
||||
|
||||
class FoundryAgent( # type: ignore[misc]
|
||||
AgentMiddlewareLayer,
|
||||
AgentTelemetryLayer,
|
||||
AgentMiddlewareLayer,
|
||||
RawFoundryAgent[FoundryAgentOptionsT],
|
||||
):
|
||||
"""Microsoft Foundry Agent with full middleware and telemetry support.
|
||||
|
||||
@@ -27,13 +27,12 @@ dependencies = [
|
||||
|
||||
[project.optional-dependencies]
|
||||
# GAIA benchmark module dependencies
|
||||
gaia = [
|
||||
"pydantic>=2.0.0",
|
||||
"opentelemetry-api>=1.39.0",
|
||||
"opentelemetry-sdk>=1.39.0,<2",
|
||||
"tqdm>=4.60.0",
|
||||
"huggingface-hub>=0.20.0",
|
||||
"orjson>=3.10.7,<4",
|
||||
gaia = [
|
||||
"pydantic>=2.0.0",
|
||||
"opentelemetry-api>=1.39.0",
|
||||
"tqdm>=4.60.0",
|
||||
"huggingface-hub>=0.20.0",
|
||||
"orjson>=3.10.7,<4",
|
||||
"pyarrow>=18.0.0", # For reading parquet files
|
||||
]
|
||||
|
||||
|
||||
@@ -39,8 +39,6 @@ dev = [
|
||||
"pytest-retry==1.7.0",
|
||||
"mypy==1.19.1",
|
||||
"pyright==1.1.408",
|
||||
"mcp[ws]>=1.24.0,<2",
|
||||
"opentelemetry-sdk>=1.39.0,<2",
|
||||
#tasks
|
||||
"poethepoet==0.42.1",
|
||||
"rich==13.7.1",
|
||||
|
||||
Generated
+23
-16
@@ -103,9 +103,7 @@ dependencies = [
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "flit", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "mypy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "poethepoet", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "prek", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "pyright", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -127,9 +125,7 @@ requires-dist = [{ name = "agent-framework-core", extras = ["all"], editable = "
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "flit", specifier = "==3.12.0" },
|
||||
{ name = "mcp", extras = ["ws"], specifier = ">=1.24.0,<2" },
|
||||
{ name = "mypy", specifier = "==1.19.1" },
|
||||
{ name = "opentelemetry-sdk", specifier = ">=1.39.0,<2" },
|
||||
{ name = "poethepoet", specifier = "==0.42.1" },
|
||||
{ name = "prek", specifier = "==0.3.4" },
|
||||
{ name = "pyright", specifier = "==1.1.408" },
|
||||
@@ -213,8 +209,6 @@ dependencies = [
|
||||
{ name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "azure-ai-agents", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "azure-ai-inference", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "azure-ai-projects", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
@@ -224,8 +218,6 @@ requires-dist = [
|
||||
{ name = "aiohttp", specifier = ">=3.7.0,<4" },
|
||||
{ name = "azure-ai-agents", specifier = ">=1.2.0b5,<1.2.0b6" },
|
||||
{ name = "azure-ai-inference", specifier = ">=1.0.0b9,<1.0.0b10" },
|
||||
{ name = "azure-ai-projects", specifier = ">=2.0.0,<3.0" },
|
||||
{ name = "azure-identity", specifier = ">=1,<2" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -347,7 +339,14 @@ name = "agent-framework-core"
|
||||
version = "1.0.0rc5"
|
||||
source = { editable = "packages/core" }
|
||||
dependencies = [
|
||||
{ name = "azure-ai-projects", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "opentelemetry-semantic-conventions-ai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -374,11 +373,9 @@ all = [
|
||||
{ name = "agent-framework-lab", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "agent-framework-mem0", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "agent-framework-ollama", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "agent-framework-openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "agent-framework-orchestrations", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "agent-framework-purview", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "agent-framework-redis", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "mcp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
@@ -402,12 +399,17 @@ requires-dist = [
|
||||
{ name = "agent-framework-lab", marker = "extra == 'all'", editable = "packages/lab" },
|
||||
{ name = "agent-framework-mem0", marker = "extra == 'all'", editable = "packages/mem0" },
|
||||
{ name = "agent-framework-ollama", marker = "extra == 'all'", editable = "packages/ollama" },
|
||||
{ name = "agent-framework-openai", marker = "extra == 'all'", editable = "packages/openai" },
|
||||
{ name = "agent-framework-orchestrations", marker = "extra == 'all'", editable = "packages/orchestrations" },
|
||||
{ name = "agent-framework-purview", marker = "extra == 'all'", editable = "packages/purview" },
|
||||
{ name = "agent-framework-redis", marker = "extra == 'all'", editable = "packages/redis" },
|
||||
{ name = "mcp", marker = "extra == 'all'", specifier = ">=1.24.0,<2" },
|
||||
{ name = "azure-ai-projects", specifier = ">=2.0.0,<3.0" },
|
||||
{ name = "azure-identity", specifier = ">=1,<2" },
|
||||
{ name = "mcp", extras = ["ws"], specifier = ">=1.24.0,<2" },
|
||||
{ name = "openai", specifier = ">=1.99.0,<3" },
|
||||
{ name = "opentelemetry-api", specifier = ">=1.39.0,<2" },
|
||||
{ name = "opentelemetry-sdk", specifier = ">=1.39.0,<2" },
|
||||
{ name = "opentelemetry-semantic-conventions-ai", specifier = ">=0.4.13,<0.4.14" },
|
||||
{ name = "packaging", specifier = ">=24.1,<25" },
|
||||
{ name = "pydantic", specifier = ">=2,<3" },
|
||||
{ name = "python-dotenv", specifier = ">=1,<2" },
|
||||
{ name = "typing-extensions", specifier = ">=4.15.0,<5" },
|
||||
@@ -447,7 +449,6 @@ dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "uvicorn", extra = ["standard"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
|
||||
@@ -468,7 +469,6 @@ requires-dist = [
|
||||
{ name = "agent-framework-orchestrations", marker = "extra == 'dev'", editable = "packages/orchestrations" },
|
||||
{ name = "fastapi", specifier = ">=0.115.0,<0.133.1" },
|
||||
{ name = "openai", specifier = ">=1.99.0,<3" },
|
||||
{ name = "opentelemetry-sdk", specifier = ">=1.39.0,<2" },
|
||||
{ name = "pytest", marker = "extra == 'all'", specifier = "==9.0.2" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.2" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0,<0.42.0" },
|
||||
@@ -565,7 +565,6 @@ dependencies = [
|
||||
gaia = [
|
||||
{ name = "huggingface-hub", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "orjson", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "pyarrow", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -608,7 +607,6 @@ requires-dist = [
|
||||
{ name = "loguru", marker = "extra == 'tau2'", specifier = ">=0.7.3" },
|
||||
{ name = "numpy", marker = "extra == 'tau2'" },
|
||||
{ name = "opentelemetry-api", marker = "extra == 'gaia'", specifier = ">=1.39.0" },
|
||||
{ name = "opentelemetry-sdk", marker = "extra == 'gaia'", specifier = ">=1.39.0,<2" },
|
||||
{ name = "orjson", marker = "extra == 'gaia'", specifier = ">=3.10.7,<4" },
|
||||
{ name = "pyarrow", marker = "extra == 'gaia'", specifier = ">=18.0.0" },
|
||||
{ name = "pydantic", marker = "extra == 'gaia'", specifier = ">=2.0.0" },
|
||||
@@ -4222,6 +4220,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2", size = 231621, upload-time = "2026-03-04T14:17:19.33Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-semantic-conventions-ai"
|
||||
version = "0.4.13"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/e6/40b59eda51ac47009fb47afcdf37c6938594a0bd7f3b9fadcbc6058248e3/opentelemetry_semantic_conventions_ai-0.4.13.tar.gz", hash = "sha256:94efa9fb4ffac18c45f54a3a338ffeb7eedb7e1bb4d147786e77202e159f0036", size = 5368, upload-time = "2025-08-22T10:14:17.387Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/35/b5/cf25da2218910f0d6cdf7f876a06bed118c4969eacaf60a887cbaef44f44/opentelemetry_semantic_conventions_ai-0.4.13-py3-none-any.whl", hash = "sha256:883a30a6bb5deaec0d646912b5f9f6dcbb9f6f72557b73d0f2560bf25d13e2d5", size = 6080, upload-time = "2025-08-22T10:14:16.477Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-set"
|
||||
version = "4.1.0"
|
||||
|
||||
Reference in New Issue
Block a user