diff --git a/docs/decisions/0007-agent-filtering-middleware.md b/docs/decisions/0007-agent-filtering-middleware.md new file mode 100644 index 0000000000..5cf1fcab0e --- /dev/null +++ b/docs/decisions/0007-agent-filtering-middleware.md @@ -0,0 +1,1190 @@ +--- +status: proposed +contact: rogerbarreto +date: 2025-09-15 +deciders: markwallace-microsoft, rogerbarreto, westey-m, dmytrostruk, sergeymenshykh +informed: {} +--- + +# Agent Filtering Middleware Design + +## Context and Problem Statement + +The current Agent Framework lacks a standardized, extensible mechanism for intercepting and processing agent execution. Developers need the ability to add custom filters/middleware to intercept and modify agent behavior at various stages of the execution pipeline. While the framework has basic agent abstractions with `RunAsync` and `RunStreamingAsync` methods, and standards like approval workflows, there is no middleware that allows developers to intercept and modify agent behavior at different agent execution contexts. + +The challenge is to design an architecture that supports: +- Multiple execution contexts (invocation, function calls, approval requests, error handling) +- Support for both streaming and non-streaming scenarios +- Dependency injection friendly setup + +## Decision Drivers + +- Agents should be able to intercept and modify agent behavior at various stages of the execution pipeline. +- The design should be simple and intuitive for developers to understand and use. +- The design should be extensible to support new execution contexts and scenarios. +- The design should support both manual and dependency injection configuration. +- The design should allow flexible custom behaviors provided by enough context information. +- The design should be exception friendly and allow clear error handling and recovery mechanisms. + +## Other AI Agent Framework Analysis + +This section provides an analysis of how other major AI agent frameworks handle filtering, middleware, hooks, or similar interception capabilities. The goal is to identify ubiquitous language, design patterns, and approaches that could inform our Agent Middleware design also providing valuable insights into achieving a more idiomatic designs. + +### Overview Comparison Table + +| Provider | Language | Supports (Y/N) | Naming | TL;DR Observation | +|---------------------------|----------|----------------|---------------------------------|------------------------| +| LangChain (Python) | Python | Y (read) | Callbacks (BaseCallbackHandler) | Uses observer pattern with event methods for interception (e.g., on_chain_start); supports agent actions and errors; handlers can read inputs/outputs and modification is limited to the parameters or by raising exceptions to influence flow. [Details](#langchain) | +| LangChain (JS) | JS | Y (read/write) | Callbacks (BaseCallbackHandler) | Similar observer pattern to Python, with event methods adapted for JS async handling; supports chain/agent interception; handlers can read inputs/outputs and modify metadata or raise exceptions to influence flow. [Details](#langchain) | +| LangChain | JS/Python/TS | Y (read/write) | Middleware | Middleware concept was recently introduced in LangChain 1.0 alpha; [Details](https://blog.langchain.com/agent-middleware/) | +| LangGraph | Python | Y (read/write) | Hooks/Callbacks (inherited from LangChain) | Event-driven with runtime handlers; integrates callbacks for observability in graphs; inherits LangChain's ability to read/modify metadata or interrupt execution. [Details](#langgraph) | +| AutoGen (Python) | Python | Y (read/write) | Reply Functions (register_reply) | Reply functions intercept and process messages; middleware-like for agent replies; can directly modify messages or replies before continuing. [Details](#autogen) | +| AutoGen (C#) | C# | Y (read/write) | Middleware (MiddlewareAgent) | Decorator/wrapper with middleware delegates for message modification; delegates can read and alter message content or options. [Details](#autogen) | +| Semantic Kernel (C#) | C# | Y (read/write) | Filters (IFunctionInvocationFilter, etc.) | Interface-based middleware pattern for function/prompt interception; filters can read and modify context, arguments, or results. [Details](#semantic-kernel) | +| Semantic Kernel (Python) | Python | Y (read/write) | Filters (add_filter, @kernel.filter decorator) | Function and decorator-based for interception; no explicit interfaces like C#, focuses on async functions for filters; can read and modify context/arguments/results. [Details](#semantic-kernel) | +| CrewAI | Python | Y (read) | Events/Callbacks (BaseEventListener) | Event-driven orchestration with listeners for workflows; listeners can observe events (e.g., read source/event data) but are primarily for logging/reactions without direct modification of workflow state. [Details](#crewai) | +| LlamaIndex | Python | Y (read) | Callbacks (CallbackManager) | Observer pattern with event methods for queries and tools; handlers can observe events/payloads (e.g., read prompts/responses) but are designed for debugging/tracing without modifying execution context. [Details](#llamaindex) | +| Haystack | Python | N (Pipeline-based interception) | N/A (Pipeline Components/Routers) | Relies on modular pipelines for implicit interception but lacks explicit middleware/filters; custom components can read/write data flow via routing/transformations, but this is compositional rather than hook-based interception. [Details](#haystack) | +| OpenAI Swarm | Python | N | N/A | No explicit middleware/filters; interception requires custom wrappers or manual handling (e.g., function decorators, client subclassing), lacking native framework support for built-in components to accept such modifications. [Details](#openai-swarm) | +| Atomic Agents | Python | N | N/A (Composable Components) | No explicit middleware/filters; modularity allows composable units but no dedicated interception hooks or callbacks for custom reading/modification mid-execution. [Details](#atomic-agents) | +| Smolagents (Hugging Face)| Python | N | N/A | No explicit support; focuses on simple agent building without interception mechanisms or hooks for reading/modifying execution. [Details](#smolagents) | +| Phidata (Agno) | Python | N | N/A | No explicit middleware/filters; agents use tools/memory but no interception hooks for custom reading/modification of calls. [Details](#phidata) | +| PromptFlow (Microsoft) | Python | N (Tracing only) | Tracing | Supports tracing for LLM interactions, acting as callbacks for debugging/iteration; tracing is read-only for observability/telemetry without options to modify context or intercept calls beyond logging. [Details](#promptflow) | +| n8n | JS/TS | Y (read/write) | Callbacks (inherited from LangChain) | AI Agent node uses LangChain under the hood, inheriting callbacks for observability; supports reading/modifying metadata or interrupting flow as in LangChain. [Details](#n8n) | + +## Considered Options + +### Option 1: Semantic Kernel Approach + +Similar to the Semantic Kernel kernel filters this option involves exposing different interface and properties for each specialized filter. + +```csharp + +var services = new ServiceCollection(); +services.AddSingleton(); +services.AddSingleton(); + +// Using DI +var agent = new MyAgent(services.BuildServiceProvider()); + +// Manual +var agent = new MyAgent(); +agent.RunFilters.Add(new MyAgentRunFilter()); +agent.FunctionCallFilters.Add(new MyAgentFunctionCallFilter()); + +public class MyAgentRunFilter : IAgentRunFilter +{ + public async Task OnRunAsync(AgentRunContext context, Func next, CancellationToken cancellationToken = default) + { + // Pre-run logic + + await next(context); + + // Post-run logic + } +} + +public interface IAgentRunFilter +{ + Task OnRunAsync(AgentRunContext context, Func next, CancellationToken cancellationToken = default); +} + +public interface IAgentFunctionCallFilter +{ + Task OnFunctionCallAsync(AgentFunctionCallContext context, Func next, CancellationToken cancellationToken = default); +} + +public class AIAgent +{ + private readonly AgentFilterProcessor _filterProcessor; + + public AIAgent(AgentFilterProcessor? filterProcessor = null) + { + _filterProcessor = filterProcessor ?? new AgentFilterProcessor(); + } + + public AIAgent(IServiceProvider serviceProvider) + { + _filterProcessor = serviceProvider.GetService() ?? new AgentFilterProcessor(); + + // Auto-register filters from DI + var filters = serviceProvider.GetServices(); + foreach (var filter in filters) + { + _filterProcessor.AddFilter(filter); + } + } + + public async Task RunAsync( + IReadOnlyCollection messages, + AgentThread? thread = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + var context = new AgentRunContext(messages, thread, options); + + // Process through filter pipeline using the same pattern as Semantic Kernel + await _filterProcessor.ProcessAsync(context, async ctx => + { + // Core agent logic - implement actual agent execution here + var response = await this.ExecuteCoreLogicAsync(ctx.Messages, ctx.Thread, ctx.Options, cancellationToken); + ctx.Response = response; + }, cancellationToken); + + // Extract the response from the context + return context.Response ?? throw new InvalidOperationException("Agent execution did not produce a response"); + } + + protected abstract Task ExecuteCoreLogicAsync( + IReadOnlyCollection messages, + AgentThread? thread, + AgentRunOptions? options, + CancellationToken cancellationToken); +} + +``` +#### Pros +- Clean separation of concerns +- Follows established patterns in Semantic Kernel and easy migration path +- No resistance or complaints from the community when used in Semantic Kernel +- Composable and reusable filter components + +#### Cons +- Adding more filters may require adding more properties to the agent class. +- Filters are not always used, and adding this responsibility to the `AIAgent` abstraction level, may be an overkill. + +### Option 2: Agent Filter Decorator Pattern + +Similar to the `OpenTelemetryAgent` and the `DelegatingChatClient` in `Microsoft.Extensions.AI`, this option involves creating decorator agents that wrap the inner agent and allow interception of method calls. The current POC implementation demonstrates two approaches: + +#### 2a. Direct Decorator Implementation (GuardrailCallbackAgent) + +```csharp +// Current POC implementation from samples +var agent = persistentAgentsClient.CreateAIAgent(model).AsBuilder() + .Use((innerAgent) => new GuardrailCallbackAgent(innerAgent)) // Decoration based agent run handling + .Use(async (context, next) => // Context based handling + { + // Guardrail: Filter input messages for PII + context.Messages = context.Messages.Select(m => new ChatMessage(m.Role, FilterPii(m.Text))).ToList(); + Console.WriteLine($"Pii Middleware - Filtered messages: {new ChatResponse(context.Messages).Text}"); + + await next(context); + + if (!context.IsStreaming) + { + // Guardrail: Filter output messages for PII + context.Messages = context.Messages.Select(m => new ChatMessage(m.Role, FilterPii(m.Text))).ToList(); + } + else + { + context.SetRawResponse(StreamingPiiDetectionAsync(context.RunStreamingResponse!)); + } + }) + .Build(); + +// Direct decorator implementation +internal sealed class GuardrailCallbackAgent : DelegatingAIAgent +{ + private readonly string[] _forbiddenKeywords = { "harmful", "illegal", "violence" }; + + public GuardrailCallbackAgent(AIAgent innerAgent) : base(innerAgent) { } + + public override async Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + var filteredMessages = this.FilterMessages(messages); + Console.WriteLine($"Guardrail Middleware - Filtered messages: {new ChatResponse(filteredMessages).Text}"); + + var response = await this.InnerAgent.RunAsync(filteredMessages, thread, options, cancellationToken); + + response.Messages = response.Messages.Select(m => new ChatMessage(m.Role, this.FilterContent(m.Text))).ToList(); + + return response; + } + + public override async IAsyncEnumerable RunStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var filteredMessages = this.FilterMessages(messages); + await foreach (var update in this.InnerAgent.RunStreamingAsync(filteredMessages, thread, options, cancellationToken)) + { + if (update.Text != null) + { + yield return new AgentRunResponseUpdate(update.Role, this.FilterContent(update.Text)); + } + else + { + yield return update; + } + } + } + + private List FilterMessages(IEnumerable messages) + { + return messages.Select(m => new ChatMessage(m.Role, this.FilterContent(m.Text))).ToList(); + } + + private string FilterContent(string content) + { + foreach (var keyword in this._forbiddenKeywords) + { + if (content.Contains(keyword, StringComparison.OrdinalIgnoreCase)) + { + return "[REDACTED: Forbidden content]"; + } + } + return content; + } +} +``` + +#### 2b. Context-Based Middleware (RunningCallbackHandlerAgent) + +The POC also includes a context-based approach using `RunningCallbackHandlerAgent` that wraps the agent and provides a context object for middleware processing: + +```csharp +// Internal implementation that supports the .Use() pattern +internal sealed class RunningCallbackHandlerAgent : DelegatingAIAgent +{ + private readonly Func, Task> _func; + + internal RunningCallbackHandlerAgent(AIAgent innerAgent, Func, Task> func) : base(innerAgent) + { + this._func = func; + } + + public override async Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + var context = new AgentInvokeCallbackContext(this, messages, thread, options, isStreaming: false, cancellationToken); + + async Task CoreLogicAsync(AgentInvokeCallbackContext ctx) + { + var response = await this.InnerAgent.RunAsync(ctx.Messages, ctx.Thread, ctx.Options, ctx.CancellationToken); + ctx.SetRawResponse(response); + } + + await this._func(context, CoreLogicAsync); + + return context.RunResponse!; + } +} +``` + +#### 2c. Function Invocation Filtering + +The POC also demonstrates function invocation filtering using a similar decorator pattern: + +```csharp +// Function invocation middleware using .Use() pattern +var agent = persistentAgentsClient.CreateAIAgent(model) + .AsBuilder() + .Use((functionInvocationContext, next, ct) => + { + Console.WriteLine($"IsStreaming: {functionInvocationContext!.IsStreaming}"); + return next(functionInvocationContext.Arguments, ct); + }) + .Use((functionInvocationContext, next, ct) => + { + Console.WriteLine($"City Name: {(functionInvocationContext!.Arguments.TryGetValue("location", out var location) ? location : "not provided")}"); + return next(functionInvocationContext.Arguments, ct); + }) + .Build(); +``` + +This demonstrates that the current POC supports both agent-level and function-level filtering through consistent patterns. + +#### Pros +- Clean separation of concerns +- Follows established patterns in `Microsoft.Extensions.AI` (DelegatingChatClient, OpenTelemetryAgent) +- Non-intrusive to existing agent implementations +- Supports both manual and DI configuration through builder pattern +- Context-specific processing middleware with `AgentInvokeCallbackContext` +- Composable and reusable filter components +- Flexible implementation allowing both direct decorators and context-based middleware +- Seamless integration with builder pattern using `.Use()` method +- Support for both streaming and non-streaming scenarios +- Rich context object providing access to messages, thread, options, and response handling + +### Option 3: Dedicated Processor Component for Middleware + +This approach involves creating a dedicated `CallbackMiddlewareProcessor` that manages collections of `ICallbackMiddleware` instances. The current POC implementation demonstrates this pattern with the `CallbackEnabledAgent` and processor architecture. + +#### Current POC Implementation + +```csharp +// Current POC usage from samples +var agent = persistentAgentsClient.CreateAIAgent(model) + .AsBuilder() + .UseCallbacks(config => + { + config.AddCallback(new PiiDetectionMiddleware()); + config.AddCallback(new GuardrailCallbackMiddleware()); + }).Build(); + +// Middleware implementation +internal sealed class PiiDetectionMiddleware : CallbackMiddleware +{ + public override async Task OnProcessAsync(AgentInvokeCallbackContext context, Func next, CancellationToken cancellationToken) + { + // Guardrail: Filter input messages for PII + context.Messages = context.Messages.Select(m => new ChatMessage(m.Role, FilterPii(m.Text))).ToList(); + Console.WriteLine($"Pii Middleware - Filtered messages: {new ChatResponse(context.Messages).Text}"); + await next(context); + + if (!context.IsStreaming) + { + // Guardrail: Filter output messages for PII + context.Messages = context.Messages.Select(m => new ChatMessage(m.Role, FilterPii(m.Text))).ToList(); + } + else + { + context.SetRawResponse(StreamingPiiDetectionAsync(context.RunStreamingResponse!)); + } + } + + private static string FilterPii(string content) + { + // PII detection logic... + } +} + +internal sealed class GuardrailCallbackMiddleware : CallbackMiddleware +{ + private readonly string[] _forbiddenKeywords = { "harmful", "illegal", "violence" }; + + public override async Task OnProcessAsync(AgentInvokeCallbackContext context, Func next, CancellationToken cancellationToken) + { + // Guardrail: Filter input messages for forbidden content + context.Messages = this.FilterMessages(context.Messages); + Console.WriteLine($"Guardrail Middleware - Filtered messages: {new ChatResponse(context.Messages).Text}"); + + await next(context); + if (!context.IsStreaming) + { + // Guardrail: Filter output messages for forbidden content + context.Messages = this.FilterMessages(context.Messages); + } + else + { + context.SetRawResponse(StreamingGuardRailAsync(context.RunStreamingResponse!)); + } + } +} +``` + +#### Function Invocation Filtering + +The POC also demonstrates function invocation filtering using the processor pattern: + +```csharp +// Processor-based function invocation middleware +var agent = persistentAgentsClient.CreateAIAgent(model) + .AsBuilder() + .UseCallbacks(config => + { + config.AddCallback(new UsedApiFunctionInvocationCallback()); + config.AddCallback(new CityInformationFunctionInvocationCallback()); + }).Build(); + +internal sealed class UsedApiFunctionInvocationCallback : CallbackMiddleware +{ + public override async Task OnProcessAsync(AgentFunctionInvocationCallbackContext context, Func next, CancellationToken cancellationToken) + { + Console.WriteLine($"IsStreaming: {context!.IsStreaming}"); + + await next(context); + } +} + +internal sealed class CityInformationFunctionInvocationCallback : CallbackMiddleware +{ + public override async Task OnProcessAsync(AgentFunctionInvocationCallbackContext context, Func next, CancellationToken cancellationToken) + { + Console.WriteLine($"City Name: {(context!.Arguments.TryGetValue("location", out var location) ? location : "not provided")}"); + await next(context); + } +} +``` + +This demonstrates that the current POC supports both agent-level and function-level filtering through consistent patterns. + +#### Processor Implementation + +The `CallbackMiddlewareProcessor` manages the filter pipeline and chain execution: + +```csharp +public sealed class CallbackMiddlewareProcessor +{ + // For thread-safety when used as a Singleton + private readonly ConcurrentBag _agentCallbacks = []; + + public CallbackMiddlewareProcessor(IEnumerable? callbacks = null) + { + if (callbacks is not null) + { + foreach (var callback in callbacks) + { + AddCallback(callback); + } + } + } + + internal CallbackMiddlewareProcessor AddCallback(ICallbackMiddleware middleware) + { + switch (middleware) + { + case CallbackMiddleware: + this._agentCallbacks.Add(middleware); + break; + default: + throw new ArgumentException($"The middleware type '{middleware.GetType().FullName}' is not supported.", nameof(middleware)); + } + + return this; + } + + public async Task ProcessAsync(TContext context, Func coreLogic, CancellationToken cancellationToken = default) + where TContext : CallbackContext + { + var applicableCallbacks = this.GetApplicableCallbacks().ToList(); + await this.InvokeChainAsync(context, applicableCallbacks, 0, coreLogic, cancellationToken); + } + + private IEnumerable GetApplicableCallbacks() + where TContext : CallbackContext + { + return this._agentCallbacks.Where(callback => callback.CanProcess()); + } +} +``` + +#### CallbackEnabledAgent Implementation + +```csharp +public sealed class CallbackEnabledAgent : DelegatingAIAgent +{ + private readonly CallbackMiddlewareProcessor _callbacksProcessor; + + public CallbackEnabledAgent(AIAgent agent, CallbackMiddlewareProcessor? callbackMiddlewareProcessor) : base(agent) + { + this._callbacksProcessor = callbackMiddlewareProcessor ?? new(); + } + + public override async Task RunAsync( + IEnumerable messages, + AgentThread? thread = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + AgentInvokeCallbackContext roamingContext = null!; + + async Task CoreLogic(AgentInvokeCallbackContext ctx) + { + roamingContext ??= ctx; + var result = await this.InnerAgent.RunAsync(ctx.Messages, ctx.Thread, ctx.Options, ctx.CancellationToken); + + ctx.SetRawResponse(result); + } + + await this._callbacksProcessor.ProcessAsync( + new AgentInvokeCallbackContext( + agent: this, + messages: messages, + thread, + options, + isStreaming: false, + cancellationToken), + CoreLogic, + cancellationToken); + + return roamingContext.RunResponse!; + } +} +``` + +#### Pros +- Flexibility: Use shared processor for multiple agents or create per-agent instances +- Clean fluent configuration API with `.UseCallbacks()` builder method +- Type-safe middleware registration with `CallbackMiddleware` base class +- Thread-safe processor implementation using `ConcurrentBag` +- Extensible context system with `AgentInvokeCallbackContext` providing rich execution context +- Seamless integration with existing agent builder pattern +- Support for both streaming and non-streaming scenarios in middleware +- Clear separation between middleware logic and agent core functionality +- Simplicity: Agents stay lean, middleware is externalized to processor +- Extensibility: Add new contexts/filters without changing agent implementation + +#### Cons +- Additional complexity with processor class and context management +- Requires understanding of middleware lifecycle and context passing +- Type switching in processor for different middleware types +- Roaming context pattern needed to capture specialized contexts through middleware chain + +## APPENDIX 1: Proposed Middleware Contexts + +The following context classes would be needed to support the filtering architecture: + +```csharp +public abstract class AgentContext +{ + // For scenarios where the filter is processed by multiple agents sounds very desirable to provide access to the invoking agent + public AIAgent Agent { get; } + + public AgentRunOptions? Options { get; set; } // Options are allowed to be set by filters + + protected AgentContext(AIAgent agent, AgentRunOptions? options) + { + Agent = agent; + Options = options; + } +} + +public class AgentRunContext : AgentContext +{ + public IList Messages { get; set; } + public AgentRunResponse? Response { get; set; } + public AgentThread? Thread { get; } + + public AgentRunContext(AIAgent agent, IList messages, AgentThread? thread, AgentRunOptions? options) + : base(agent, options) + { + Messages = messages; + Thread = thread; + } +} + +public class AgentFunctionInvocationContext : AgentToolContext +{ + // Similar to MEAI.FunctionInvocationContext + public AIFunction Function { get; set; } + public AIFunctionArguments Arguments { get; set; } + public FunctionCallContent CallContent { get; set; } + public IList Messages { get; set; } + public ChatOptions? Options { get; set; } + public int Iteration { get; set; } + public int FunctionCallIndex { get; set; } + public int FunctionCount { get; set; } + public bool Terminate { get; set; } + public bool IsStreaming { get; set; } +} + +``` + +## APPENDIX 2: Setting Up Middleware Options + +### 1. Semantic Kernel Setup + +Has the benefit of clear separation of concerns, but this approach requires developers +to manage and maintain separate collections for each filter type, increasing code complexity and maintenance overhead. + +```csharp +// Use Case +var agent = new MyAgent(); +agent.RunFilters.Add(new MyAgentRunFilter()); +agent.RunFilters.Add(new MyMultipleFilterImplementation()); +agent.FunctionCallFilters.Add(new MyAgentFunctionCallFilter()); +agent.FunctionCallFilters.Add(new MyMultipleFilterImplementation()); +agent.AYZFilters.Add(new MyAgentAYZFilter()); +agent.AYZFilters.Add(new MyMultipleFilterImplementation()); + + + +// Impl +interface IAgentRunFilter +{ + Task OnRunAsync(AgentRunContext context, Func next, CancellationToken cancellationToken = default); +} +interface IAgentFunctionCallFilter +{ + Task OnFunctionCallAsync(AgentFunctionCallContext context, Func next, CancellationToken cancellationToken = default); +} +``` + +#### Pros +- Clean separation of concerns +- Follows established patterns in Semantic Kernel and easy migration path +- No resistance or complaints from the community when used in Semantic Kernel + +#### Cons +- Adding more filters may require adding more properties to the agent/processor class. +- Adding more filters requires bigger code changes downstream to callers. + +### 2. Setup with Generic Method + +Instead of properties, exposing as a method may be more appropriate while still maintaining those filters in separate buckets internally. + +```csharp +// Use Case +var agent = new MyAgent(); +agent.AddFilters([new MyAgentRunFilter(), new MyMultipleFilterImplementation()]); +agent.AddFilters([new MyAgentFunctionCallFilter(), new MyMultipleFilterImplementation()]); +agent.AddFilters([new MyAgentAYZFilter(), new MyMultipleFilterImplementation()]); + +``` + +#### Pros +- Clean separation of concerns +- Cleaner API for adding filters compared to option 1 +- No resistance or complaints from the community when used in Semantic Kernel + +#### Cons +- Adding more filters may require adding more properties to the agent/processor class. +- Adding more filters requires bigger code changes downstream to callers. + +### 3. Setup with Filter Hierarchy, Fully Generic Setup + +In a more generic approach, filters can be grouped in the same bucket and processed based on the context. +One generic interface for all filters, with context-specific implementations. +Allow simple grouping of filters in the same list and adding new filter types with low code-changes. + +```csharp +// Use Case +var agent = new MyAgent(); +agent.Filters.Add(new MyAgentRunFilter()); +agent.Filters.Add(new MyAgentFunctionCallFilter()); +agent.Filters.Add(new MyAgentAYZFilter()); +agent.Filters.Add(new MyMultipleFilterImplementation()); + +// OR Via constructor (Also DI Friendly) +var agent = new MyAgent(new List { + new MyAgentRunFilter(), + new MyAgentFunctionCallFilter(), + new MyAgentAYZFilter(), + new MyMultipleFilterImplementation() }); + +// Impl +interface IAgentFilter +{ + bool CanProcess(AgentContext context); + Task OnProcessAsync(AgentContext context, Func next, CancellationToken cancellationToken = default); +} + +interface IAgentFilter : IAgentFilter where T : AgentContext +{ + Task OnProcessAsync(T context, Func next, CancellationToken cancellationToken = default); +} + +class MySingleFilterImplementation : IAgentFilter +{ + public bool CanProcess(AgentContext context) + => context is AgentRunContext; + + public async Task OnProcessAsync(AgentContext context, Func next, CancellationToken cancellationToken = default) + { + Func wrappedNext = async ctx => await next(ctx); + await OnProcessAsync((AgentRunContext)context, wrappedNext, cancellationToken); + } + + public async Task OnProcessAsync(AgentRunContext context, Func next, CancellationToken cancellationToken = default) + { + // Pre-run logic + await next(context); + // Post-run logic + } +} + +class MyMultipleFilterImplementation : IAgentFilter, IAgentFilter +{ + public bool CanProcess(AgentContext context) + => context is AgentRunContext or FunctionCallAgentContext; + + public async Task OnProcessAsync(AgentContext context, Func next, CancellationToken cancellationToken = default) + { + if (context is AgentRunContext runContext) + { + Func wrappedNext = async ctx => await next(ctx); + await OnProcessAsync(runContext, wrappedNext, cancellationToken); + return; + } + + if (context is FunctionCallAgentContext callContext) + { + Func wrappedNext = async ctx => await next(ctx); + await OnProcessAsync(callContext, wrappedNext, cancellationToken); + return; + } + + await next(context); + } + + public async Task OnProcessAsync(AgentRunContext context, Func next, CancellationToken cancellationToken = default) + { + // Pre-run logic + await next(context); + // Post-run logic + } + + public async Task OnProcessAsync(FunctionCallAgentContext context, Func next, CancellationToken cancellationToken = default) + { + // Pre-function call logic + await next(context); + // Post-function call logic + } +} +``` + +#### Pros +- Simple grouping of filters in the same list, help with DI registration and filtering iteration +- Lower maintenance and learning curve when adding new filter types +- Can be combined with other patterns like the `AgentFilterProcessor` + +#### Cons +- Less clear separation of concerns compared to dedicated filter types +- Requires extra runtime type checking and casting for context-specific processing + +## Decision Outcome + +- **Option 2 (Decorator Pattern)** is the preferred approach for the following reasons: + - Adding a processor pattern seems an overkill as we can achieve same results without introducing new abstractions and complexity. + - Direct decorator on agents and tools for agent and function invocation middleware. + - Support for Context-based middleware also leveraging closer patterns to Semantic Kernel filters. + - Agent Builder pattern integration with `.Use()` method for fluent configuration + +**Key POC Insights**: +1. Both patterns actually work +2. The decorator pattern offers more direct control and simpler and more flexible implementation +2. The processor seems an overkill compared to decorator as it adds more extra abstractions and complexity +4. Function invocation filtering is supported in both patterns +5. Streaming scenarios are well-supported in both approaches +6. Function approval request filtering is supported in both patterns +7. Builder pattern added as part of the POC is a must-have and mades both approaches developer-friendly + +## Appendix: Other AI Agent Framework Analysis Details + +#### LangChain + +LangChain uses callbacks for interception, which can be passed at runtime or during construction. + +Naming (Python): Callbacks (BaseCallbackHandler) +Supports: Y (read/write) +Observation: Uses observer pattern with event methods for interception (e.g., on_chain_start); supports agent actions and errors; handlers can read inputs/outputs and modify metadata or raise exceptions to influence flow. + +**Python Example:** For more details, see the official documentation: [Callbacks - Python LangChain](https://python.langchain.com/docs/concepts/callbacks/). + +```python +from langchain_core.callbacks import BaseCallbackHandler + +class MyHandler(BaseCallbackHandler): + def on_chain_start(self, serialized, inputs, **kwargs): + inputs['number'] += 1 # Modify inputs (write capability) + print("Chain started!") + +handler = MyHandler() + +# Pass callback at runtime +chain.invoke({"number": 25}, {"callbacks": [handler]}) + +# Or at constructor time +chain = SomeChain(callbacks=[handler]) +chain.invoke({"number": 25}) +``` + +Naming (JS): Callbacks (BaseCallbackHandler) +Supports: Y (read/write) +Observation: Similar observer pattern to Python, with event methods adapted for JS async handling; supports chain/agent interception; handlers can read inputs/outputs and modify metadata or raise exceptions to influence flow. + +**JS Example:** For more details, see the official documentation: [Callbacks - LangChain.js](https://js.langchain.com/docs/concepts/callbacks/). (Adapted for async handling in JS.) + +```javascript +import { BaseCallbackHandler } from "@langchain/core/callbacks/base"; + +class MyHandler extends BaseCallbackHandler { + name = "my_handler"; + + async handleChainStart(chain, inputs) { + inputs.number += 1; # Modify inputs (write capability) + console.log("Chain started!"); + } +} + +const handler = new MyHandler(); + +// Pass callback at runtime +await chain.invoke({ number: 25 }, { callbacks: [handler] }); + +// Or at constructor time +const chainWithHandler = new SomeChain({ callbacks: [handler] }); +await chainWithHandler.invoke({ number: 25 }); +``` + +#### LangGraph + +LangGraph inherits callbacks from LangChain and often uses them with handlers for observability (e.g., via Langfuse). + +Naming (Python): Hooks/Callbacks (inherited from LangChain) +Supports: Y (read/write) +Observation: Event-driven with runtime handlers; integrates callbacks for observability in graphs; inherits LangChain's ability to read/modify metadata or interrupt execution. + +For more details, see the official documentation (inherited from LangChain): [Callbacks - Python LangChain](https://python.langchain.com/docs/concepts/callbacks/). Here's an example of streaming with a callback handler (Python): + +```python +from langfuse.langchain import CallbackHandler +from langchain_core.messages import HumanMessage + +class MyLangfuseHandler(CallbackHandler): + def on_chain_start(self, serialized, inputs, **kwargs): + inputs['messages'][0].content += " modified" # Modify input messages (write capability) + super().on_chain_start(serialized, inputs, **kwargs) + +langfuse_handler = MyLangfuseHandler() + +# Stream with callback in config +for s in graph.stream( + {"messages": [HumanMessage(content="What is Langfuse?")]}, + config={"callbacks": [langfuse_handler]} +): + print(s) +``` + +#### AutoGen + +AutoGen supports middleware-like behavior in both languages. + +Naming (Python): Reply Functions (register_reply) +Supports: Y (read/write) +Observation: Reply functions intercept and process messages; middleware-like for agent replies; can directly modify messages or replies before continuing. + +**Python Example:** For more details, see the official documentation: [agentchat.conversable_agent | AutoGen 0.2](https://microsoft.github.io/autogen/0.2/docs/reference/agentchat/conversable_agent). Uses `register_reply` to add reply functions that intercept and process messages. + +```python +def print_messages(recipient, messages, sender, config): + if "callback" in config and config["callback"] is not None: + callback = config["callback"] + callback(sender, recipient, messages[-1]) + messages[-1]["content"] += " modified" # Modify last message content (write capability) + print(f"Messages sent to: {recipient.name} | num messages: {len(messages)}") + return False, None # required to ensure the agent communication flow continues + +user_proxy.register_reply( + [autogen.Agent, None], + reply_func=print_messages, + config={"callback": None}, +) + +assistant.register_reply( + [autogen.Agent, None], + reply_func=print_messages, + config={"callback": None}, +) +``` + +Naming (C#): Middleware (MiddlewareAgent) +Supports: Y (read/write) +Observation: Decorator/wrapper with middleware delegates for message modification; delegates can read and alter message content or options. + +**C# Example:** For more details, see the official documentation: [Use middleware in an agent - AutoGen for .NET](https://microsoft.github.io/autogen-for-net/articles/Middleware-overview.html). Registers middleware to modify messages. + +```csharp +// Register middleware to modify messages +var middlewareAgent = new MiddlewareAgent(innerAgent: agent); +middlewareAgent.Use(async (messages, options, agent, ct) => +{ + if (messages.Last() is TextMessage lastMessage && lastMessage.Content.Contains("Hello World")) + { + lastMessage.Content = $"[middleware] {lastMessage.Content}"; # Modify message content (write capability) + return lastMessage; + } + return await agent.GenerateReplyAsync(messages, options, ct); +}); +``` + +#### Semantic Kernel + +Semantic Kernel uses filters added to the kernel for interception during function invocation, prompt rendering, etc. Implementations differ by language: C# use interfaces, while Python uses functions and decorators. + +Naming (C#): Filters (IFunctionInvocationFilter, etc.) +Supports: Y (read/write) +Observation: Interface-based middleware for function/prompt interception; filters can read and modify context, arguments, or results. + +**C# Example:** For more details, see the official documentation: [Semantic Kernel Filters | Microsoft Learn](https://learn.microsoft.com/en-us/semantic-kernel/concepts/enterprise-readiness/filters). Adding a function invocation filter using interfaces. + +```csharp +using Microsoft.SemanticKernel; + +IKernelBuilder builder = Kernel.CreateBuilder(); +builder.Services.AddSingleton(); + +Kernel kernel = builder.Build(); + +// Alternatively, add directly +kernel.FunctionInvocationFilters.Add(new LoggingFilter(logger)); + +// Define the filter +public sealed class LoggingFilter(ILogger logger) : IFunctionInvocationFilter +{ + public async Task OnFunctionInvocationAsync(FunctionInvocationContext context, Func next) + { + context.Arguments["new_arg"] = "modified_value"; # Modify arguments by adding a new key (write capability) + logger.LogInformation("Invoking {FunctionName}", context.Function.Name); + await next(context); + logger.LogInformation("Invoked {FunctionName}", context.Function.Name); + } +} +``` + +Naming (Python): Filters (add_filter, @kernel.filter decorator) +Supports: Y (read/write) +Observation: Function and decorator-based for interception; no explicit interfaces like C#, focuses on async functions for filters; can read and modify context/arguments/results. + +**Python Example:** For more details, see the official documentation: [Semantic Kernel Filters | Microsoft Learn](https://learn.microsoft.com/en-us/semantic-kernel/concepts/enterprise-readiness/filters). Adding function invocation filters (one as a standalone function and one via decorator). + +```python +import logging +from typing import Callable, Coroutine, Any +from semantic_kernel import Kernel +from semantic_kernel.filters import FilterTypes, FunctionInvocationContext +from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion +from semantic_kernel.contents import ChatHistory +from semantic_kernel.exceptions import OperationCancelledException + +logger = logging.getLogger(__name__) + +async def input_output_filter( + context: FunctionInvocationContext, + next: Callable[[FunctionInvocationContext], Coroutine[Any, Any, None]], +) -> None: + if context.function.plugin_name != "chat": + await next(context) + return + try: + user_input = input("User:> ") + except (KeyboardInterrupt, EOFError) as exc: + raise OperationCancelledException("User stopped the operation") from exc + if user_input == "exit": + raise OperationCancelledException("User stopped the operation") + context.arguments["chat_history"].add_user_message(user_input) # Modify arguments by adding message (write capability) + + await next(context) + + if context.result: + logger.info(f"Usage: {context.result.metadata.get('usage')}") + context.arguments["chat_history"].add_message(context.result.value[0]) + print(f"Mosscap:> {context.result!s}") + +kernel = Kernel() +kernel.add_service(AzureChatCompletion(service_id="chat-gpt")) + +# Add filter as a standalone function +kernel.add_filter("function_invocation", input_output_filter) + +# Add filter via decorator +@kernel.filter(filter_type=FilterTypes.FUNCTION_INVOCATION) +async def exception_catch_filter( + context: FunctionInvocationContext, next: Coroutine[FunctionInvocationContext, Any, None] +): + try: + await next(context) + except Exception as e: + logger.info(e) + +# Example invocation (assuming a "chat" plugin is added) +history = ChatHistory() +result = await kernel.invoke( + function_name="chat", + plugin_name="chat", + chat_history=history, +) +``` + +#### CrewAI + +CrewAI uses event listeners for callbacks. + +Naming (Python): Events/Callbacks (BaseEventListener) +Supports: Y (read) +Observation: Event-driven orchestration with listeners for workflows; listeners can observe events (e.g., read source/event data) but are primarily for logging/reactions without direct modification of workflow state. + +For more details, see the official documentation: [Event Listeners - CrewAI Documentation](https://docs.crewai.com/concepts/event-listener). Here's an example of setting up a custom listener (Python): + +```python +from crewai.utilities.events import ( + CrewKickoffStartedEvent, + BaseEventListener, + crewai_event_bus +) + +class MyCustomListener(BaseEventListener): + def setup_listeners(self, crewai_event_bus): + @crewai_event_bus.on(CrewKickoffStartedEvent) + def on_crew_started(source, event): + print(f"Crew '{event.crew_name}' started!") + +my_listener = MyCustomListener() # Automatically registers on init + +# Use in a crew +crew = Crew(agents=[...], tasks=[...]) +``` + +#### LlamaIndex + +LlamaIndex uses callback managers with handlers. + +Naming (Python): Callbacks (CallbackManager, BaseCallbackHandler) +Supports: Y (read) +Observation: Observer pattern with event methods for queries and tools; handlers can observe events/payloads (e.g., read prompts/responses) but are designed for debugging/tracing without modifying execution context. + +For more details, see the official documentation: [Callbacks - LlamaIndex](https://docs.llamaindex.ai/en/stable/module_guides/observability/callbacks/). Here's an example setup (Python): + +```python +from llama_index.core.callbacks import CallbackManager, LlamaDebugHandler + +debug_handler = LlamaDebugHandler() # Concrete handler subclassing BaseCallbackHandler +callback_manager = CallbackManager([debug_handler]) + +# Assign to components, e.g., an index or query engine +index = VectorStoreIndex.from_documents(documents, callback_manager=callback_manager) +query_engine = index.as_query_engine() +response = query_engine.query("What is this about?") +``` + +#### Haystack + +Haystack does not support explicit middleware or filters like the others. Instead, it uses a modular pipeline architecture for interception via components (e.g., ConditionalRouter for routing based on conditions like tool calls) and observability through logging/tracing integrations (e.g., Langfuse). + +Naming (Python): N/A (Pipeline Components/Routers) +Supports: N (Pipeline-based interception) +Observation: Relies on modular pipelines for implicit interception but lacks explicit middleware/filters; custom components can read/write data flow via routing/transformations, but this is compositional rather than hook-based interception. + +For more details, see the official documentation: [Pipelines - Haystack Documentation](https://docs.haystack.deepset.ai/docs/pipelines). Here's an example of pipeline-based interception with a custom collector component (Python): + +```python +from haystack import Pipeline +from haystack.components.generators.chat import OpenAIChatGenerator +from haystack.components.routers import ConditionalRouter +from haystack.components.tools import ToolInvoker +from haystack.tools import ComponentTool +from haystack.components.websearch import SerperDevWebSearch +from haystack.dataclasses import ChatMessage +from typing import Any, Dict, List +from haystack import component +from haystack.core.component.types import Variadic + +# Custom component to collect/observe messages (for interception/observation) +@component() +class MessageCollector: + def __init__(self): + self._messages = [] + @component.output_types(messages=List[ChatMessage]) + def run(self, messages: Variadic[List[ChatMessage]]) -> Dict[str, Any]: + self._messages.extend([msg for inner in messages for msg in inner]) + return {"messages": self._messages} + def clear(self): + self._messages = [] + +# Define a tool +web_tool = ComponentTool(component=SerperDevWebSearch(top_k=3)) + +# Define routes for filtering (e.g., check for tool calls) +routes = [ + { + "condition": "{{replies[0].tool_calls | length > 0}}", + "output": "{{replies}}", + "output_name": "there_are_tool_calls", + "output_type": List[ChatMessage], + }, + { + "condition": "{{replies[0].tool_calls | length == 0}}", + "output": "{{replies}}", + "output_name": "final_replies", + "output_type": List[ChatMessage], + }, +] + +# Build the pipeline +pipeline = Pipeline() +pipeline.add_component("generator", OpenAIChatGenerator(model="gpt-4o-mini")) +pipeline.add_component("router", ConditionalRouter(routes=routes)) +pipeline.add_component("tool_invoker", ToolInvoker(tools=[web_tool])) +pipeline.add_component("message_collector", MessageCollector()) + +# Connect components (interception via routing and collection) +pipeline.connect("generator.replies", "router.replies") +pipeline.connect("router.there_are_tool_calls", "tool_invoker.messages") +pipeline.connect("tool_invoker.messages", "message_collector.messages") +pipeline.connect("router.final_replies", "message_collector.messages") + +# Run the pipeline (observes via collector, filters via router) +result = pipeline.run({"generator": {"messages": [ChatMessage.from_user("What's the weather in Berlin?")]}}) +print(result["message_collector"]["messages"]) +``` + +#### OpenAI Swarm + +OpenAI Swarm does not provide native support for middleware, filters, callbacks, or hooks. While interception can be achieved through custom implementations (e.g., function wrappers, client subclassing, or manual tool execution with `execute_tools=False`), this requires the caller to implement their own logic, which is not considered built-in framework support. + +Naming (Python): N/A +Supports: N +Observation: No explicit middleware/filters; interception requires custom wrappers or manual handling (e.g., function decorators, client subclassing), lacking native framework support for built-in components to accept such modifications. + +For more details, see the official GitHub repository: [OpenAI Swarm GitHub](https://github.com/openai/swarm). No native code examples available for interception; custom approaches are possible but not framework-native. + +#### Atomic Agents + +Atomic Agents does not support explicit middleware, callbacks, hooks, or filters. Its modularity allows composable components, but no dedicated interception mechanisms are documented. + +Naming (Python): N/A (Composable Components) +Supports: N +Observation: No explicit middleware/filters; modularity allows composable units but no dedicated interception hooks or callbacks for custom reading/modification mid-execution. + +For more details, see the official documentation: [Atomic Agents Docs](https://brainblend-ai.github.io/atomic-agents/). No specific code examples available for interception. + +#### Smolagents (Hugging Face) + +Smolagents does not support explicit middleware, callbacks, hooks, or filters; it focuses on simple agent building. + +Naming (Python): N/A +Supports: N +Observation: No explicit support; focuses on simple agent building without interception mechanisms or hooks for reading/modifying execution. + +For more details, see the official documentation: [Smolagents Docs](https://huggingface.co/docs/smolagents/en/index). No specific code examples available for interception. + +#### Phidata (Agno) + +Phidata (Agno) does not support explicit middleware, callbacks, hooks, or filters; agents rely on tools and memory. + +Naming (Python): N/A +Supports: N +Observation: No explicit middleware/filters; agents use tools/memory but no interception hooks for custom reading/modification of calls. + +For more details, see the official documentation: [Phidata Docs](https://docs.phidata.com/). No specific code examples available for interception. + +#### PromptFlow (Microsoft) + +PromptFlow supports tracing for LLM interactions, which acts like callbacks for debugging and iteration. + +Naming (Python): Tracing +Supports: N (Tracing only) +Observation: Supports tracing for LLM interactions, acting as callbacks for debugging/iteration; tracing is read-only for observability/telemetry without options to modify context or intercept calls beyond logging. + +For more details, see the official documentation: [Tracing in PromptFlow](https://microsoft.github.io/promptflow/how-to-guides/tracing/index.html). No direct code examples in the browsed content, but tracing is integrated into flow debugging (Python). + +#### n8n + +n8n's AI Agent node inherits callbacks from LangChain for observability in workflows. + +Naming (JS/TS): Callbacks (inherited from LangChain) +Supports: Y (read/write) +Observation: AI Agent node uses LangChain under the hood, inheriting callbacks for observability; supports reading/modifying metadata or interrupting flow as in LangChain. + +For more details, see the official documentation: [AI Agent Node Docs](https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.agent/). (Inherits from LangChain; refer to LangChain docs for callback examples.) No specific n8n-unique code in the content, but uses LangChain's observer pattern. Here's an adapted LangChain JS example for consistency: + +```javascript +import { BaseCallbackHandler } from "@langchain/core/callbacks/base"; + +class MyHandler extends BaseCallbackHandler { + name = "my_handler"; + + async handleChainStart(chain, inputs) { + inputs.number += 1; # Modify inputs (write capability) + console.log("Chain started!"); + } +} + +const handler = new MyHandler(); + +// Pass callback at runtime +await chain.invoke({ number: 25 }, { callbacks: [handler] }); + +// Or at constructor time +const chainWithHandler = new SomeChain({ callbacks: [handler] }); +await chainWithHandler.invoke({ number: 25 }); +``` diff --git a/dotnet/.editorconfig b/dotnet/.editorconfig index 7f1d309dc4..57997400cd 100644 --- a/dotnet/.editorconfig +++ b/dotnet/.editorconfig @@ -170,7 +170,7 @@ dotnet_diagnostic.RCS1173.severity = warning # Use coalesce expression instead o dotnet_diagnostic.RCS1186.severity = warning # Use Regex instance instead of static method. dotnet_diagnostic.RCS1188.severity = warning # Remove redundant auto-property initialization. dotnet_diagnostic.RCS1197.severity = suggestion # Optimize StringBuilder.AppendLine call. -dotnet_diagnostic.RCS1201.severity = warning # Use method chaining. +dotnet_diagnostic.RCS1201.severity = suggestion # Use method chaining. dotnet_diagnostic.IDE0001.severity = warning # Simplify name dotnet_diagnostic.IDE0002.severity = warning # Simplify member access diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 7ae4f52a03..a94b2d656c 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -48,6 +48,7 @@ + @@ -292,13 +293,15 @@ - + + + diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/Agent_Step14_Middleware.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/Agent_Step14_Middleware.csproj new file mode 100644 index 0000000000..f65635790d --- /dev/null +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/Agent_Step14_Middleware.csproj @@ -0,0 +1,24 @@ + + + + Exe + net9.0 + 12 + + enable + disable + VSTHRD200;CA1707 + + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/Program.cs new file mode 100644 index 0000000000..5ef0a5db8c --- /dev/null +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/Program.cs @@ -0,0 +1,271 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows multiple middleware layers working together with Azure OpenAI: +// chat client (global/per-request), agent run (PII filtering and guardrails), +// function invocation (logging and result overrides), and human-in-the-loop +// approval workflows for sensitive function calls. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + +// Get Azure AI Foundry configuration from environment variables +var endpoint = Environment.GetEnvironmentVariable("AZUREOPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZUREOPENAI_ENDPOINT is not set."); +var deploymentName = System.Environment.GetEnvironmentVariable("AZUREOPENAI_DEPLOYMENT_NAME") ?? "gpt-4o"; + +// Get a client to create/retrieve server side agents with +var azureOpenAIClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) + .GetChatClient(deploymentName); + +[Description("Get the weather for a given location.")] +static string GetWeather([Description("The location to get the weather for.")] string location) + => $"The weather in {location} is cloudy with a high of 15°C."; + +[Description("The current datetime offset.")] +static string GetDateTime() + => DateTimeOffset.Now.ToString(); + +// Adding middleware to the chat client level +var chatClient = azureOpenAIClient.AsIChatClient() + .AsBuilder() + .Use(getResponseFunc: ChatClientMiddleware, getStreamingResponseFunc: null) + .Build(); + +// For flexibility we create the agent without any middleware. +var originalAgent = new ChatClientAgent(chatClient, new ChatClientAgentOptions( + instructions: "You are an AI assistant that helps people find information.", + // Agent level tools + tools: [AIFunctionFactory.Create(GetDateTime, name: nameof(GetDateTime))])); + +// Adding middleware to the agent level +var middlewareEnabledAgent = originalAgent + .AsBuilder() + .Use(FunctionCallMiddleware) + .Use(FunctionCallOverrideWeather) + .Use(PIIMiddleware, null) + .Use(GuardrailMiddleware, null) + .Build(); + +var thread = middlewareEnabledAgent.GetNewThread(); + +Console.WriteLine("\n\n=== Example 1: Wording Guardrail ==="); +var guardRailedResponse = await middlewareEnabledAgent.RunAsync("Tell me something harmful."); +Console.WriteLine($"Guard railed response: {guardRailedResponse}"); + +Console.WriteLine("\n\n=== Example 2: PII detection ==="); +var piiResponse = await middlewareEnabledAgent.RunAsync("My name is John Doe, call me at 123-456-7890 or email me at john@something.com"); +Console.WriteLine($"Pii filtered response: {piiResponse}"); + +Console.WriteLine("\n\n=== Example 3: Agent function middleware ==="); + +// Agent function middleware support is limited to agents that wraps a upstream ChatClientAgent or derived from it. + +// Add Per-request tools +var options = new ChatClientAgentRunOptions(new() +{ + Tools = [AIFunctionFactory.Create(GetWeather, name: nameof(GetWeather))] +}); + +var functionCallResponse = await middlewareEnabledAgent.RunAsync("What's the current time and the weather in Seattle?", thread, options); +Console.WriteLine($"Function calling response: {functionCallResponse}"); + +// Special per-request middleware agent. +Console.WriteLine("\n\n=== Example 4: Per-request middleware with human in the loop function approval ==="); + +var optionsWithApproval = new ChatClientAgentRunOptions(new() +{ + // Adding a function with approval required + Tools = [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(GetWeather, name: nameof(GetWeather)))], +}) +{ + ChatClientFactory = (chatClient) => chatClient + .AsBuilder() + .Use(PerRequestChatClientMiddleware, null) // Using the non-streaming for handling streaming as well + .Build() +}; + +// var response = middlewareAgent // Using per-request middleware pipeline in addition to existing agent-level middleware +var response = await originalAgent // Using per-request middleware pipeline without existing agent-level middleware + .AsBuilder() + .Use(PerRequestFunctionCallingMiddleware) + .Use(ConsolePromptingApprovalMiddleware, null) + .Build() + .RunAsync("What's the current time and the weather in Seattle?", thread, optionsWithApproval); + +Console.WriteLine($"Per-request middleware response: {response}"); + +// Function invocation middleware that logs before and after function calls. +async ValueTask FunctionCallMiddleware(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) +{ + Console.WriteLine($"Function Name: {context!.Function.Name} - Middleware 1 Pre-Invoke"); + var result = await next(context, cancellationToken); + Console.WriteLine($"Function Name: {context!.Function.Name} - Middleware 1 Post-Invoke"); + + return result; +} + +// Function invocation middleware that overrides the result of the GetWeather function. +async ValueTask FunctionCallOverrideWeather(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) +{ + Console.WriteLine($"Function Name: {context!.Function.Name} - Middleware 2 Pre-Invoke"); + + var result = await next(context, cancellationToken); + + if (context.Function.Name == nameof(GetWeather)) + { + // Override the result of the GetWeather function + result = "The weather is sunny with a high of 25°C."; + } + Console.WriteLine($"Function Name: {context!.Function.Name} - Middleware 2 Post-Invoke"); + return result; +} + +// There's no difference per-request middleware, except it's added to the agent and used for a single agent run. +// This middleware logs function names before and after they are invoked. +async ValueTask PerRequestFunctionCallingMiddleware(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) +{ + Console.WriteLine($"Agent Id: {agent.Id}"); + Console.WriteLine($"Function Name: {context!.Function.Name} - Per-Request Pre-Invoke"); + var result = await next(context, cancellationToken); + Console.WriteLine($"Function Name: {context!.Function.Name} - Per-Request Post-Invoke"); + return result; +} + +// This middleware redacts PII information from input and output messages. +async Task PIIMiddleware(IEnumerable messages, AgentThread? thread, AgentRunOptions? options, AIAgent innerAgent, CancellationToken cancellationToken) +{ + // Redact PII information from input messages + var filteredMessages = FilterMessages(messages); + Console.WriteLine("Pii Middleware - Filtered Messages Pre-Run"); + + var response = await innerAgent.RunAsync(filteredMessages, thread, options, cancellationToken).ConfigureAwait(false); + + // Redact PII information from output messages + response.Messages = FilterMessages(response.Messages); + + Console.WriteLine("Pii Middleware - Filtered Messages Post-Run"); + + return response; + + static IList FilterMessages(IEnumerable messages) + { + return messages.Select(m => new ChatMessage(m.Role, FilterPii(m.Text))).ToList(); + } + + static string FilterPii(string content) + { + // Regex patterns for PII detection (simplified for demonstration) + Regex[] piiPatterns = [ + new(@"\b\d{3}-\d{3}-\d{4}\b", RegexOptions.Compiled), // Phone number (e.g., 123-456-7890) + new(@"\b[\w\.-]+@[\w\.-]+\.\w+\b", RegexOptions.Compiled), // Email address + new(@"\b[A-Z][a-z]+\s[A-Z][a-z]+\b", RegexOptions.Compiled) // Full name (e.g., John Doe) + ]; + + foreach (var pattern in piiPatterns) + { + content = pattern.Replace(content, "[REDACTED: PII]"); + } + + return content; + } +} + +// This middleware enforces guardrails by redacting certain keywords from input and output messages. +async Task GuardrailMiddleware(IEnumerable messages, AgentThread? thread, AgentRunOptions? options, AIAgent innerAgent, CancellationToken cancellationToken) +{ + // Redact keywords from input messages + var filteredMessages = FilterMessages(messages); + + Console.WriteLine("Guardrail Middleware - Filtered messages Pre-Run"); + + // Proceed with the agent run + var response = await innerAgent.RunAsync(filteredMessages, thread, options, cancellationToken); + + // Redact keywords from output messages + response.Messages = FilterMessages(response.Messages); + + Console.WriteLine("Guardrail Middleware - Filtered messages Post-Run"); + + return response; + + List FilterMessages(IEnumerable messages) + { + return messages.Select(m => new ChatMessage(m.Role, FilterContent(m.Text))).ToList(); + } + + static string FilterContent(string content) + { + foreach (var keyword in new[] { "harmful", "illegal", "violence" }) + { + if (content.Contains(keyword, StringComparison.OrdinalIgnoreCase)) + { + return "[REDACTED: Forbidden content]"; + } + } + + return content; + } +} + +// This middleware handles Human in the loop console interaction for any user approval required during function calling. +async Task ConsolePromptingApprovalMiddleware(IEnumerable messages, AgentThread? thread, AgentRunOptions? options, AIAgent innerAgent, CancellationToken cancellationToken) +{ + var response = await innerAgent.RunAsync(messages, thread, options, cancellationToken); + + var userInputRequests = response.UserInputRequests.ToList(); + + while (userInputRequests.Count > 0) + { + // Ask the user to approve each function call request. + // For simplicity, we are assuming here that only function approval requests are being made. + + // Pass the user input responses back to the agent for further processing. + response.Messages = userInputRequests + .OfType() + .Select(functionApprovalRequest => + { + Console.WriteLine($"The agent would like to invoke the following function, please reply Y to approve: Name {functionApprovalRequest.FunctionCall.Name}"); + return new ChatMessage(ChatRole.User, [functionApprovalRequest.CreateResponse(Console.ReadLine()?.Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false)]); + }) + .ToList(); + + response = await innerAgent.RunAsync(response.Messages, thread, options, cancellationToken); + + userInputRequests = response.UserInputRequests.ToList(); + } + + return response; +} + +// This middleware handles chat client lower level invocations. +// This is useful for handling agent messages before they are sent to the LLM and also handle any response messages from the LLM before they are sent back to the agent. +async Task ChatClientMiddleware(IEnumerable message, ChatOptions? options, IChatClient innerChatClient, CancellationToken cancellationToken) +{ + Console.WriteLine("Chat Client Middleware - Pre-Chat"); + var response = await innerChatClient.GetResponseAsync(message, options, cancellationToken); + Console.WriteLine("Chat Client Middleware - Post-Chat"); + + return response; +} + +// There's no difference per-request middleware, except it's added to the chat client and used for a single agent run. +// This middleware handles chat client lower level invocations. +// This is useful for handling agent messages before they are sent to the LLM and also handle any response messages from the LLM before they are sent back to the agent. +async Task PerRequestChatClientMiddleware(IEnumerable message, ChatOptions? options, IChatClient innerChatClient, CancellationToken cancellationToken) +{ + Console.WriteLine("Per-Request Chat Client Middleware - Pre-Chat"); + var response = await innerChatClient.GetResponseAsync(message, options, cancellationToken); + Console.WriteLine("Per-Request Chat Client Middleware - Post-Chat"); + + return response; +} diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/README.md b/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/README.md new file mode 100644 index 0000000000..142e0ce445 --- /dev/null +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/README.md @@ -0,0 +1,41 @@ +# Agent Middleware + +This sample demonstrates how to add middleware to intercept: +- Chat client calls (global and per‑request) +- Agent runs (guardrails and PII filtering) +- Function calling (logging/override) + +## What This Sample Shows + +1. Azure OpenAI integration via `AzureOpenAIClient` and `AzureCliCredential` +2. Chat client middleware using `ChatClientBuilder.Use(...)` +3. Agent run middleware (PII redaction and wording guardrails) +4. Function invocation middleware (logging and overriding a tool result) +5. Per‑request chat client middleware +6. Per‑request function pipeline with approval +7. Combining agent‑level and per‑request middleware + +## Function Invocation Middleware + +Not all agents support function invocation middleware. + +Attempting to use function middleware on agents that do not wrap a ChatClientAgent or derives from it will throw an InvalidOperationException. + +## Prerequisites + +1. Environment variables: + - `AZUREOPENAI_ENDPOINT`: Your Azure OpenAI endpoint + - `AZUREOPENAI_DEPLOYMENT_NAME`: Chat deployment name (optional; defaults to `gpt-4o`) +2. Sign in with Azure CLI (PowerShell): + ```powershell + az login + ``` + +## Running the Sample + +Use PowerShell: +```powershell +cd dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware +dotnet run +``` + diff --git a/dotnet/samples/GettingStarted/Agents/README.md b/dotnet/samples/GettingStarted/Agents/README.md index ecd14d8462..18e59ceb01 100644 --- a/dotnet/samples/GettingStarted/Agents/README.md +++ b/dotnet/samples/GettingStarted/Agents/README.md @@ -39,6 +39,7 @@ Before you begin, ensure you have the following prerequisites: |[Using images with a simple agent](./Agent_Step11_UsingImages/)|This sample demonstrates how to use image multi-modality with an AI agent| |[Exposing a simple agent as a function tool](./Agent_Step12_AsFunctionTool/)|This sample demonstrates how to expose an agent as a function tool| |[Using memory with an agent](./Agent_Step13_Memory/)|This sample demonstrates how to create a simple memory component and use it with an agent| +|[Using middleware with an agent](./Agent_Step14_Middleware/)|This sample demonstrates how to use middleware with an agent| ## Running the samples from the console diff --git a/dotnet/samples/SemanticKernelMigration/NotMigratedUseCases/SemanticKernelBasic/.github/SemanticKernelToAgentFrameworkReport.md b/dotnet/samples/SemanticKernelMigration/NotMigratedUseCases/SemanticKernelBasic/.github/SemanticKernelToAgentFrameworkReport.md deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/PersistentAgentResponseExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/PersistentAgentResponseExtensions.cs index d5bc4abf94..f7e443aed3 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/PersistentAgentResponseExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/PersistentAgentResponseExtensions.cs @@ -16,15 +16,16 @@ internal static class PersistentAgentResponseExtensions /// The response containing the persistent agent to be converted. Cannot be . /// The client used to interact with persistent agents. Cannot be . /// The default to use when interacting with the agent. + /// Provides a way to customize the creation of the underlying used by the agent. /// A instance that can be used to perform operations on the persistent agent. - public static ChatClientAgent AsAIAgent(this Response persistentAgentResponse, PersistentAgentsClient persistentAgentsClient, ChatOptions? chatOptions = null) + public static ChatClientAgent AsAIAgent(this Response persistentAgentResponse, PersistentAgentsClient persistentAgentsClient, ChatOptions? chatOptions = null, Func? clientFactory = null) { if (persistentAgentResponse is null) { throw new ArgumentNullException(nameof(persistentAgentResponse)); } - return AsAIAgent(persistentAgentResponse.Value, persistentAgentsClient, chatOptions); + return AsAIAgent(persistentAgentResponse.Value, persistentAgentsClient, chatOptions, clientFactory); } /// @@ -33,8 +34,9 @@ internal static class PersistentAgentResponseExtensions /// The persistent agent metadata to be converted. Cannot be . /// The client used to interact with persistent agents. Cannot be . /// The default to use when interacting with the agent. + /// Provides a way to customize the creation of the underlying used by the agent. /// A instance that can be used to perform operations on the persistent agent. - public static ChatClientAgent AsAIAgent(this PersistentAgent persistentAgentMetadata, PersistentAgentsClient persistentAgentsClient, ChatOptions? chatOptions = null) + public static ChatClientAgent AsAIAgent(this PersistentAgent persistentAgentMetadata, PersistentAgentsClient persistentAgentsClient, ChatOptions? chatOptions = null, Func? clientFactory = null) { if (persistentAgentMetadata is null) { @@ -48,6 +50,11 @@ internal static class PersistentAgentResponseExtensions var chatClient = persistentAgentsClient.AsNewIChatClient(persistentAgentMetadata.Id); + if (clientFactory is not null) + { + chatClient = clientFactory(chatClient); + } + return new ChatClientAgent(chatClient, options: new() { Id = persistentAgentMetadata.Id, diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/PersistentAgentsClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/PersistentAgentsClientExtensions.cs index 5e4192b2f2..108dd4f1eb 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/PersistentAgentsClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/PersistentAgentsClientExtensions.cs @@ -17,12 +17,14 @@ public static class PersistentAgentsClientExtensions /// A for the persistent agent. /// The ID of the server side agent to create a for. /// Options that should apply to all runs of the agent. + /// Provides a way to customize the creation of the underlying used by the agent. /// The to monitor for cancellation requests. The default is . /// A instance that can be used to perform operations on the persistent agent. public static ChatClientAgent GetAIAgent( this PersistentAgentsClient persistentAgentsClient, string agentId, ChatOptions? chatOptions = null, + Func? clientFactory = null, CancellationToken cancellationToken = default) { if (persistentAgentsClient is null) @@ -36,7 +38,7 @@ public static class PersistentAgentsClientExtensions } var persistentAgentResponse = persistentAgentsClient.Administration.GetAgent(agentId, cancellationToken); - return persistentAgentResponse.AsAIAgent(persistentAgentsClient, chatOptions); + return persistentAgentResponse.AsAIAgent(persistentAgentsClient, chatOptions, clientFactory); } /// @@ -46,12 +48,14 @@ public static class PersistentAgentsClientExtensions /// A for the persistent agent. /// The ID of the server side agent to create a for. /// Options that should apply to all runs of the agent. + /// Provides a way to customize the creation of the underlying used by the agent. /// The to monitor for cancellation requests. The default is . /// A instance that can be used to perform operations on the persistent agent. public static async Task GetAIAgentAsync( this PersistentAgentsClient persistentAgentsClient, string agentId, ChatOptions? chatOptions = null, + Func? clientFactory = null, CancellationToken cancellationToken = default) { if (persistentAgentsClient is null) @@ -65,7 +69,7 @@ public static class PersistentAgentsClientExtensions } var persistentAgentResponse = await persistentAgentsClient.Administration.GetAgentAsync(agentId, cancellationToken).ConfigureAwait(false); - return persistentAgentResponse.AsAIAgent(persistentAgentsClient, chatOptions); + return persistentAgentResponse.AsAIAgent(persistentAgentsClient, chatOptions, clientFactory); } /// @@ -82,6 +86,7 @@ public static class PersistentAgentsClientExtensions /// The top-p setting for the agent. /// The response format for the agent. /// The metadata for the agent. + /// Provides a way to customize the creation of the underlying used by the agent. /// The to monitor for cancellation requests. The default is . /// A instance that can be used to perform operations on the newly created agent. public static async Task CreateAIAgentAsync( @@ -96,6 +101,7 @@ public static class PersistentAgentsClientExtensions float? topP = null, BinaryData? responseFormat = null, IReadOnlyDictionary? metadata = null, + Func? clientFactory = null, CancellationToken cancellationToken = default) { if (persistentAgentsClient is null) @@ -116,7 +122,7 @@ public static class PersistentAgentsClientExtensions cancellationToken: cancellationToken).ConfigureAwait(false); // Get a local proxy for the agent to work with. - return await persistentAgentsClient.GetAIAgentAsync(createPersistentAgentResponse.Value.Id, cancellationToken: cancellationToken).ConfigureAwait(false); + return await persistentAgentsClient.GetAIAgentAsync(createPersistentAgentResponse.Value.Id, clientFactory: clientFactory, cancellationToken: cancellationToken).ConfigureAwait(false); } /// @@ -133,6 +139,7 @@ public static class PersistentAgentsClientExtensions /// The top-p setting for the agent. /// The response format for the agent. /// The metadata for the agent. + /// Provides a way to customize the creation of the underlying used by the agent. /// The to monitor for cancellation requests. The default is . /// A instance that can be used to perform operations on the newly created agent. public static ChatClientAgent CreateAIAgent( @@ -147,6 +154,7 @@ public static class PersistentAgentsClientExtensions float? topP = null, BinaryData? responseFormat = null, IReadOnlyDictionary? metadata = null, + Func? clientFactory = null, CancellationToken cancellationToken = default) { if (persistentAgentsClient is null) @@ -167,7 +175,7 @@ public static class PersistentAgentsClientExtensions cancellationToken: cancellationToken); // Get a local proxy for the agent to work with. - return persistentAgentsClient.GetAIAgent(createPersistentAgentResponse.Value.Id, cancellationToken: cancellationToken); + return persistentAgentsClient.GetAIAgent(createPersistentAgentResponse.Value.Id, clientFactory: clientFactory, cancellationToken: cancellationToken); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/AssistantClientResultExtensions.cs b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/AssistantClientResultExtensions.cs index 70065d804a..c92dda7105 100644 --- a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/AssistantClientResultExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/AssistantClientResultExtensions.cs @@ -17,15 +17,20 @@ public static class AssistantExtensions /// The client result containing the assistant. /// The assistant client. /// Optional chat options. + /// Provides a way to customize the creation of the underlying used by the agent. /// A instance that can be used to perform operations on the assistant. - public static ChatClientAgent AsAIAgent(this ClientResult assistantClientResult, AssistantClient assistantClient, ChatOptions? chatOptions = null) + public static ChatClientAgent AsAIAgent( + this ClientResult assistantClientResult, + AssistantClient assistantClient, + ChatOptions? chatOptions = null, + Func? clientFactory = null) { if (assistantClientResult is null) { throw new ArgumentNullException(nameof(assistantClientResult)); } - return AsAIAgent(assistantClientResult.Value, assistantClient, chatOptions); + return AsAIAgent(assistantClientResult.Value, assistantClient, chatOptions, clientFactory); } /// @@ -34,8 +39,13 @@ public static class AssistantExtensions /// The assistant metadata. /// The assistant client. /// Optional chat options. + /// Provides a way to customize the creation of the underlying used by the agent. /// A instance that can be used to perform operations on the assistant. - public static ChatClientAgent AsAIAgent(this Assistant assistantMetadata, AssistantClient assistantClient, ChatOptions? chatOptions = null) + public static ChatClientAgent AsAIAgent( + this Assistant assistantMetadata, + AssistantClient assistantClient, + ChatOptions? chatOptions = null, + Func? clientFactory = null) { if (assistantMetadata is null) { @@ -48,6 +58,11 @@ public static class AssistantExtensions var chatClient = assistantClient.AsIChatClient(assistantMetadata.Id); + if (clientFactory is not null) + { + chatClient = clientFactory(chatClient); + } + return new ChatClientAgent(chatClient, options: new() { Id = assistantMetadata.Id, diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIAssistantClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIAssistantClientExtensions.cs index cbc28fb268..ecc46d7514 100644 --- a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIAssistantClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIAssistantClientExtensions.cs @@ -26,12 +26,14 @@ public static class OpenAIAssistantClientExtensions /// The to create the with. /// The ID of the server side agent to create a for. /// Options that should apply to all runs of the agent. + /// Provides a way to customize the creation of the underlying used by the agent. /// The to monitor for cancellation requests. The default is . /// A instance that can be used to perform operations on the assistant agent. public static ChatClientAgent GetAIAgent( this AssistantClient assistantClient, string agentId, ChatOptions? chatOptions = null, + Func? clientFactory = null, CancellationToken cancellationToken = default) { if (assistantClient is null) @@ -45,7 +47,7 @@ public static class OpenAIAssistantClientExtensions } var assistant = assistantClient.GetAssistant(agentId, cancellationToken); - return assistant.AsAIAgent(assistantClient, chatOptions); + return assistant.AsAIAgent(assistantClient, chatOptions, clientFactory); } /// @@ -54,12 +56,14 @@ public static class OpenAIAssistantClientExtensions /// The to create the with. /// The ID of the server side agent to create a for. /// Options that should apply to all runs of the agent. + /// Provides a way to customize the creation of the underlying used by the agent. /// The to monitor for cancellation requests. The default is . /// A instance that can be used to perform operations on the assistant agent. public static async Task GetAIAgentAsync( this AssistantClient assistantClient, string agentId, ChatOptions? chatOptions = null, + Func? clientFactory = null, CancellationToken cancellationToken = default) { if (assistantClient is null) @@ -74,7 +78,7 @@ public static class OpenAIAssistantClientExtensions var assistanceResponse = await assistantClient.GetAssistantAsync(agentId, cancellationToken).ConfigureAwait(false); - return assistanceResponse.AsAIAgent(assistantClient, chatOptions); + return assistanceResponse.AsAIAgent(assistantClient, chatOptions, clientFactory); } /// @@ -86,11 +90,20 @@ public static class OpenAIAssistantClientExtensions /// Optional name for the agent for identification purposes. /// Optional description of the agent's capabilities and purpose. /// Optional collection of AI tools that the agent can use during conversations. + /// Provides a way to customize the creation of the underlying used by the agent. /// Optional logger factory for enabling logging within the agent. /// An instance backed by the OpenAI Assistant service. /// Thrown when or is . /// Thrown when is empty or whitespace. - public static AIAgent CreateAIAgent(this AssistantClient client, string model, string? instructions = null, string? name = null, string? description = null, IList? tools = null, ILoggerFactory? loggerFactory = null) => + public static AIAgent CreateAIAgent( + this AssistantClient client, + string model, + string? instructions = null, + string? name = null, + string? description = null, + IList? tools = null, + Func? clientFactory = null, + ILoggerFactory? loggerFactory = null) => client.CreateAIAgent( model, new ChatClientAgentOptions() @@ -103,6 +116,7 @@ public static class OpenAIAssistantClientExtensions Tools = tools, } }, + clientFactory, loggerFactory); /// @@ -111,11 +125,17 @@ public static class OpenAIAssistantClientExtensions /// The OpenAI to use for the agent. /// The model identifier to use (e.g., "gpt-4"). /// Full set of options to configure the agent. + /// Provides a way to customize the creation of the underlying used by the agent. /// Optional logger factory for enabling logging within the agent. /// An instance backed by the OpenAI Assistant service. /// Thrown when or or is . /// Thrown when is empty or whitespace. - public static AIAgent CreateAIAgent(this AssistantClient client, string model, ChatClientAgentOptions options, ILoggerFactory? loggerFactory = null) + public static AIAgent CreateAIAgent( + this AssistantClient client, + string model, + ChatClientAgentOptions options, + Func? clientFactory = null, + ILoggerFactory? loggerFactory = null) { Throw.IfNull(client); Throw.IfNullOrEmpty(model); @@ -163,7 +183,14 @@ public static class OpenAIAssistantClientExtensions } }; - return new ChatClientAgent(client.AsIChatClient(assistantId), agentOptions, loggerFactory); + var chatClient = client.AsIChatClient(assistantId); + + if (clientFactory is not null) + { + chatClient = clientFactory(chatClient); + } + + return new ChatClientAgent(chatClient, agentOptions, loggerFactory); } /// @@ -175,13 +202,21 @@ public static class OpenAIAssistantClientExtensions /// Optional name for the agent for identification purposes. /// Optional description of the agent's capabilities and purpose. /// Optional collection of AI tools that the agent can use during conversations. + /// Provides a way to customize the creation of the underlying used by the agent. /// Optional logger factory for enabling logging within the agent. /// An instance backed by the OpenAI Assistant service. /// Thrown when or is . /// Thrown when is empty or whitespace. - public static async Task CreateAIAgentAsync(this AssistantClient client, string model, string? instructions = null, string? name = null, string? description = null, IList? tools = null, ILoggerFactory? loggerFactory = null) => - await client.CreateAIAgentAsync( - model, + public static async Task CreateAIAgentAsync( + this AssistantClient client, + string model, + string? instructions = null, + string? name = null, + string? description = null, + IList? tools = null, + Func? clientFactory = null, + ILoggerFactory? loggerFactory = null) => + await client.CreateAIAgentAsync(model, new ChatClientAgentOptions() { Name = name, @@ -192,6 +227,7 @@ public static class OpenAIAssistantClientExtensions Tools = tools, } }, + clientFactory, loggerFactory).ConfigureAwait(false); /// @@ -200,11 +236,17 @@ public static class OpenAIAssistantClientExtensions /// The OpenAI to use for the agent. /// The model identifier to use (e.g., "gpt-4"). /// Full set of options to configure the agent. + /// Provides a way to customize the creation of the underlying used by the agent. /// Optional logger factory for enabling logging within the agent. /// An instance backed by the OpenAI Assistant service. /// Thrown when or is . /// Thrown when is empty or whitespace. - public static async Task CreateAIAgentAsync(this AssistantClient client, string model, ChatClientAgentOptions options, ILoggerFactory? loggerFactory = null) + public static async Task CreateAIAgentAsync( + this AssistantClient client, + string model, + ChatClientAgentOptions options, + Func? clientFactory = null, + ILoggerFactory? loggerFactory = null) { Throw.IfNull(client); Throw.IfNull(model); @@ -252,6 +294,13 @@ public static class OpenAIAssistantClientExtensions } }; - return new ChatClientAgent(client.AsIChatClient(assistantId), agentOptions, loggerFactory); + var chatClient = client.AsIChatClient(assistantId); + + if (clientFactory is not null) + { + chatClient = clientFactory(chatClient); + } + + return new ChatClientAgent(chatClient, agentOptions, loggerFactory); } } diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIChatClientExtensions.cs index f0484af02a..6b48c04182 100644 --- a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIChatClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIChatClientExtensions.cs @@ -28,10 +28,18 @@ public static class OpenAIChatClientExtensions /// Optional name for the agent for identification purposes. /// Optional description of the agent's capabilities and purpose. /// Optional collection of AI tools that the agent can use during conversations. + /// Provides a way to customize the creation of the underlying used by the agent. /// Optional logger factory for enabling logging within the agent. /// An instance backed by the OpenAI Chat Completion service. /// Thrown when is . - public static AIAgent CreateAIAgent(this ChatClient client, string? instructions = null, string? name = null, string? description = null, IList? tools = null, ILoggerFactory? loggerFactory = null) => + public static AIAgent CreateAIAgent( + this ChatClient client, + string? instructions = null, + string? name = null, + string? description = null, + IList? tools = null, + Func? clientFactory = null, + ILoggerFactory? loggerFactory = null) => client.CreateAIAgent( new ChatClientAgentOptions() { @@ -43,6 +51,7 @@ public static class OpenAIChatClientExtensions Tools = tools, } }, + clientFactory, loggerFactory); /// @@ -50,15 +59,26 @@ public static class OpenAIChatClientExtensions /// /// The OpenAI to use for the agent. /// Full set of options to configure the agent. + /// Provides a way to customize the creation of the underlying used by the agent. /// Optional logger factory for enabling logging within the agent. /// An instance backed by the OpenAI Chat Completion service. /// Thrown when or is . - public static AIAgent CreateAIAgent(this ChatClient client, ChatClientAgentOptions options, ILoggerFactory? loggerFactory = null) + public static AIAgent CreateAIAgent( + this ChatClient client, + ChatClientAgentOptions options, + Func? clientFactory = null, + ILoggerFactory? loggerFactory = null) { Throw.IfNull(client); Throw.IfNull(options); var chatClient = client.AsIChatClient(); + + if (clientFactory is not null) + { + chatClient = clientFactory(chatClient); + } + return new ChatClientAgent(chatClient, options, loggerFactory); } } diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs index 890326fc74..ebc648e724 100644 --- a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs @@ -28,10 +28,18 @@ public static class OpenAIResponseClientExtensions /// Optional name for the agent for identification purposes. /// Optional description of the agent's capabilities and purpose. /// Optional collection of AI tools that the agent can use during conversations. + /// Provides a way to customize the creation of the underlying used by the agent. /// Optional logger factory for enabling logging within the agent. /// An instance backed by the OpenAI Response service. /// Thrown when is . - public static AIAgent CreateAIAgent(this OpenAIResponseClient client, string? instructions = null, string? name = null, string? description = null, IList? tools = null, ILoggerFactory? loggerFactory = null) + public static AIAgent CreateAIAgent( + this OpenAIResponseClient client, + string? instructions = null, + string? name = null, + string? description = null, + IList? tools = null, + Func? clientFactory = null, + ILoggerFactory? loggerFactory = null) { Throw.IfNull(client); @@ -46,6 +54,7 @@ public static class OpenAIResponseClientExtensions Tools = tools, } }, + clientFactory, loggerFactory); } @@ -54,14 +63,26 @@ public static class OpenAIResponseClientExtensions /// /// The to use for the agent. /// Full set of options to configure the agent. + /// Provides a way to customize the creation of the underlying used by the agent. /// Optional logger factory for enabling logging within the agent. /// An instance backed by the OpenAI Response service. /// Thrown when or is . - public static AIAgent CreateAIAgent(this OpenAIResponseClient client, ChatClientAgentOptions options, ILoggerFactory? loggerFactory = null) + public static AIAgent CreateAIAgent( + this OpenAIResponseClient client, + ChatClientAgentOptions options, + Func? clientFactory = null, + ILoggerFactory? loggerFactory = null) { Throw.IfNull(client); Throw.IfNull(options); - return new ChatClientAgent(client.AsIChatClient(), options, loggerFactory); + var chatClient = client.AsIChatClient(); + + if (clientFactory is not null) + { + chatClient = clientFactory(chatClient); + } + + return new ChatClientAgent(chatClient, options, loggerFactory); } } diff --git a/dotnet/src/Microsoft.Agents.AI/AIAgentBuilder.cs b/dotnet/src/Microsoft.Agents.AI/AIAgentBuilder.cs new file mode 100644 index 0000000000..fe1656b92a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/AIAgentBuilder.cs @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// A builder for creating pipelines of . +public sealed class AIAgentBuilder +{ + private readonly Func _innerAgentFactory; + + /// The registered agent factory instances. + private List>? _agentFactories; + + /// Initializes a new instance of the class. + /// The inner that represents the underlying backend. + /// is . + public AIAgentBuilder(AIAgent innerAgent) + { + _ = Throw.IfNull(innerAgent); + this._innerAgentFactory = _ => innerAgent; + } + + /// Initializes a new instance of the class. + /// A callback that produces the inner that represents the underlying backend. + public AIAgentBuilder(Func innerAgentFactory) + { + this._innerAgentFactory = Throw.IfNull(innerAgentFactory); + } + + /// + public AIAgent Build(IServiceProvider? services = null) + { + services ??= EmptyServiceProvider.Instance; + var agent = this._innerAgentFactory(services); + + // To match intuitive expectations, apply the factories in reverse order, so that the first factory added is the outermost. + if (this._agentFactories is not null) + { + for (var i = this._agentFactories.Count - 1; i >= 0; i--) + { + agent = this._agentFactories[i](agent, services); + if (agent is null) + { + Throw.InvalidOperationException( + $"The {nameof(AIAgentBuilder)} entry at index {i} returned null. " + + $"Ensure that the callbacks passed to {nameof(Use)} return non-null {nameof(AIAgent)} instances."); + } + } + } + + return agent; + } + + /// + public AIAgentBuilder Use(Func agentFactory) + { + _ = Throw.IfNull(agentFactory); + + return this.Use((innerAgent, _) => agentFactory(innerAgent)); + } + + /// + public AIAgentBuilder Use(Func agentFactory) + { + _ = Throw.IfNull(agentFactory); + + (this._agentFactories ??= []).Add(agentFactory); + return this; + } + + /// + /// Adds to the agent pipeline an anonymous delegating agent based on a delegate that provides + /// an implementation for both and . + /// + /// + /// A delegate that provides the implementation for both and + /// . This delegate is invoked with the list of messages, the agent + /// thread, the run options, a delegate that represents invoking the inner agent, and a cancellation token. The delegate should be passed + /// whatever messages, thread, options, and cancellation token should be passed along to the next stage in the pipeline. + /// It will handle both the non-streaming and streaming cases. + /// + /// The updated instance. + /// + /// This overload can be used when the anonymous implementation needs to provide pre-processing and/or post-processing, but doesn't + /// need to interact with the results of the operation, which will come from the inner agent. + /// + /// is . + public AIAgentBuilder Use(Func, AgentThread?, AgentRunOptions?, Func, AgentThread?, AgentRunOptions?, CancellationToken, Task>, CancellationToken, Task> sharedFunc) + { + _ = Throw.IfNull(sharedFunc); + + return this.Use((innerAgent, _) => new AnonymousDelegatingAIAgent(innerAgent, sharedFunc)); + } + + /// + /// Adds to the agent pipeline an anonymous delegating agent based on a delegate that provides + /// an implementation for both and . + /// + /// + /// A delegate that provides the implementation for . When , + /// must be non-null, and the implementation of + /// will use for the implementation. + /// + /// + /// A delegate that provides the implementation for . When , + /// must be non-null, and the implementation of + /// will use for the implementation. + /// + /// The updated instance. + /// + /// One or both delegates can be provided. If both are provided, they will be used for their respective methods: + /// will provide the implementation of , and + /// will provide the implementation of . + /// If only one of the delegates is provided, it will be used for both methods. That means that if + /// is supplied without , the implementation of + /// will employ limited streaming, as it will be operating on the batch output produced by . And if + /// is supplied without , the implementation of + /// will be implemented by combining the updates from . + /// + /// Both and are . + public AIAgentBuilder Use( + Func, AgentThread?, AgentRunOptions?, AIAgent, CancellationToken, Task>? runFunc, + Func, AgentThread?, AgentRunOptions?, AIAgent, CancellationToken, IAsyncEnumerable>? runStreamingFunc) + { + AnonymousDelegatingAIAgent.ThrowIfBothDelegatesNull(runFunc, runStreamingFunc); + + return this.Use((innerAgent, _) => new AnonymousDelegatingAIAgent(innerAgent, runFunc, runStreamingFunc)); + } + + /// + /// Provides an empty implementation. + /// + private sealed class EmptyServiceProvider : IServiceProvider, IKeyedServiceProvider + { + /// Gets the singleton instance of . + public static EmptyServiceProvider Instance { get; } = new(); + + /// + public object? GetService(Type serviceType) => null; + + /// + public object? GetKeyedService(Type serviceType, object? serviceKey) => null; + + /// + public object GetRequiredKeyedService(Type serviceType, object? serviceKey) => + throw new InvalidOperationException($"No service for type '{serviceType}' has been registered."); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/AIAgentBuilderAIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI/AIAgentBuilderAIAgentExtensions.cs new file mode 100644 index 0000000000..fa758926f1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/AIAgentBuilderAIAgentExtensions.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// Provides extension methods for working with in the context of . +public static class AIAgentBuilderAIAgentExtensions +{ + /// Creates a new using as its inner agent. + /// The agent to use as the inner agent. + /// The new instance. + /// + /// This method is equivalent to using the constructor directly, + /// specifying as the inner agent. + /// + /// is . + public static AIAgentBuilder AsBuilder(this AIAgent innerAgent) + { + _ = Throw.IfNull(innerAgent); + + return new AIAgentBuilder(innerAgent); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/AIAgentBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI/AIAgentBuilderExtensions.cs new file mode 100644 index 0000000000..f837c5d4f7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/AIAgentBuilderExtensions.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Provides extension methods for configuring an instance. +/// +/// This class contains methods that extend the functionality of the to +/// allow additional customization and behavior injection. +public static class AIAgentBuilderExtensions +{ + /// + /// Adds a middleware to the AI agent pipeline that intercepts and processes invocations. + /// + /// The to which the middleware is added. + /// A delegate that processes function invocations. The delegate receives the invocation context, the next + /// middleware in the pipeline, and a cancellation token, and returns a task representing the result of the + /// invocation. + /// The instance with the middleware added. + public static AIAgentBuilder Use(this AIAgentBuilder builder, Func>, CancellationToken, ValueTask> callback) + { + _ = Throw.IfNull(builder); + _ = Throw.IfNull(callback); + return builder.Use((innerAgent, _) => + { + // Function calling requires a ChatClientAgent inner agent. + if (innerAgent.GetService() is null) + { + throw new InvalidOperationException($"The function invocation middleware can only be used with decorations of a {nameof(AIAgent)} that support usage of FunctionInvokingChatClient decorated chat clients."); + } + + return new FunctionInvocationDelegatingAgent(innerAgent, callback); + }); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/AnonymousDelegatingAIAgent.cs b/dotnet/src/Microsoft.Agents.AI/AnonymousDelegatingAIAgent.cs new file mode 100644 index 0000000000..21fbfda639 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/AnonymousDelegatingAIAgent.cs @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// Represents a delegating AI agent that wraps an inner agent with implementations provided by delegates. +/// +/// This internal class is a convenience implementation mainly used to support Use methods that take delegates to intercept agent operations. +/// +internal sealed class AnonymousDelegatingAIAgent : DelegatingAIAgent +{ + /// The delegate to use as the implementation of . + private readonly Func, AgentThread?, AgentRunOptions?, AIAgent, CancellationToken, Task>? _runFunc; + + /// The delegate to use as the implementation of . + /// + /// When non-, this delegate is used as the implementation of and + /// will be invoked with the same arguments as the method itself. + /// When , will delegate directly to the inner agent. + /// + private readonly Func, AgentThread?, AgentRunOptions?, AIAgent, CancellationToken, IAsyncEnumerable>? _runStreamingFunc; + + /// The delegate to use as the implementation of both and . + private readonly Func, AgentThread?, AgentRunOptions?, Func, AgentThread?, AgentRunOptions?, CancellationToken, Task>, CancellationToken, Task>? _sharedFunc; + + /// + /// Initializes a new instance of the class. + /// + /// The inner agent. + /// + /// A delegate that provides the implementation for both and . + /// In addition to the arguments for the operation, it's provided with a delegate to the inner agent that should be + /// used to perform the operation on the inner agent. It will handle both the non-streaming and streaming cases. + /// + /// + /// This overload may be used when the anonymous implementation needs to provide pre-processing and/or post-processing, but doesn't + /// need to interact with the results of the operation, which will come from the inner agent. + /// + /// is . + /// is . + public AnonymousDelegatingAIAgent( + AIAgent innerAgent, + Func, AgentThread?, AgentRunOptions?, Func, AgentThread?, AgentRunOptions?, CancellationToken, Task>, CancellationToken, Task> sharedFunc) + : base(innerAgent) + { + _ = Throw.IfNull(sharedFunc); + + this._sharedFunc = sharedFunc; + } + + /// + /// Initializes a new instance of the class. + /// + /// The inner agent. + /// + /// A delegate that provides the implementation for . When , + /// must be non-null, and the implementation of + /// will use for the implementation. + /// + /// + /// A delegate that provides the implementation for . When , + /// must be non-null, and the implementation of + /// will use for the implementation. + /// + /// is . + /// Both and are . + public AnonymousDelegatingAIAgent( + AIAgent innerAgent, + Func, AgentThread?, AgentRunOptions?, AIAgent, CancellationToken, Task>? runFunc, + Func, AgentThread?, AgentRunOptions?, AIAgent, CancellationToken, IAsyncEnumerable>? runStreamingFunc) + : base(innerAgent) + { + ThrowIfBothDelegatesNull(runFunc, runStreamingFunc); + + this._runFunc = runFunc; + this._runStreamingFunc = runStreamingFunc; + } + + /// + public override Task RunAsync( + IEnumerable messages, + AgentThread? thread = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(messages); + + if (this._sharedFunc is not null) + { + return GetRunViaSharedAsync(messages, thread, options, cancellationToken); + + async Task GetRunViaSharedAsync( + IEnumerable messages, AgentThread? thread, AgentRunOptions? options, CancellationToken cancellationToken) + { + AgentRunResponse? response = null; + + await this._sharedFunc( + messages, + thread, + options, + async (messages, thread, options, cancellationToken) + => response = await this.InnerAgent.RunAsync(messages, thread, options, cancellationToken).ConfigureAwait(false), + cancellationToken) + .ConfigureAwait(false); + + if (response is null) + { + Throw.InvalidOperationException("The shared delegate completed successfully without producing an AgentRunResponse."); + } + + return response; + } + } + else if (this._runFunc is not null) + { + return this._runFunc(messages, thread, options, this.InnerAgent, cancellationToken); + } + else + { + Debug.Assert(this._runStreamingFunc is not null, "Expected non-null streaming delegate."); + return this._runStreamingFunc!(messages, thread, options, this.InnerAgent, cancellationToken) + .ToAgentRunResponseAsync(cancellationToken); + } + } + + /// + public override IAsyncEnumerable RunStreamingAsync( + IEnumerable messages, + AgentThread? thread = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(messages); + + if (this._sharedFunc is not null) + { + var updates = Channel.CreateBounded(1); + + _ = ProcessAsync(); + async Task ProcessAsync() + { + Exception? error = null; + try + { + await this._sharedFunc(messages, thread, options, async (messages, thread, options, cancellationToken) => + { + await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, thread, options, cancellationToken).ConfigureAwait(false)) + { + await updates.Writer.WriteAsync(update, cancellationToken).ConfigureAwait(false); + } + }, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + error = ex; + throw; + } + finally + { + _ = updates.Writer.TryComplete(error); + } + } + + return updates.Reader.ReadAllAsync(cancellationToken); + } + else if (this._runStreamingFunc is not null) + { + return this._runStreamingFunc(messages, thread, options, this.InnerAgent, cancellationToken); + } + else + { + Debug.Assert(this._runFunc is not null, "Expected non-null non-streaming delegate."); + return GetStreamingRunAsyncViaRunAsync(this._runFunc!(messages, thread, options, this.InnerAgent, cancellationToken)); + + static async IAsyncEnumerable GetStreamingRunAsyncViaRunAsync(Task task) + { + AgentRunResponse response = await task.ConfigureAwait(false); + foreach (var update in response.ToAgentRunResponseUpdates()) + { + yield return update; + } + } + } + } + + /// Throws an exception if both of the specified delegates are . + /// Both and are . + internal static void ThrowIfBothDelegatesNull(object? runFunc, object? runStreamingFunc) + { + if (runFunc is null && runStreamingFunc is null) + { + Throw.ArgumentNullException(nameof(runFunc), $"At least one of the {nameof(runFunc)} or {nameof(runStreamingFunc)} delegates must be non-null."); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/AgentChatClientBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/AgentChatClientBuilderExtensions.cs deleted file mode 100644 index ffa571f7cf..0000000000 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/AgentChatClientBuilderExtensions.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using Microsoft.Extensions.AI; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI; - -/// Provides extensions for configuring instances. -public static class AgentChatClientBuilderExtensions -{ - /// - /// Enables automatic function call invocation on the chat pipeline. - /// - /// This works by adding an instance of with default options. - /// The being used to build the chat pipeline. - /// The supplied . - /// is . - public static ChatClientBuilder UseAgentInvocation( - this ChatClientBuilder builder) - { - _ = Throw.IfNull(builder); - - return builder.Use((innerClient, services) => - new AgentInvokedChatClient(innerClient)); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/AgentInvokedChatClient.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/AgentInvokedChatClient.cs deleted file mode 100644 index 713d1f72e6..0000000000 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/AgentInvokedChatClient.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI; - -/// -/// Internal chat client that handle agent invocation details for the chat client pipeline. -/// -internal sealed class AgentInvokedChatClient : DelegatingChatClient -{ - /// - /// Initializes a new instance of the class. - /// - /// The chat client to invoke agents. - internal AgentInvokedChatClient(IChatClient chatClient) - : base(chatClient) - { - } -} diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs index 963e6affbe..5f714be24f 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs @@ -71,7 +71,7 @@ public sealed class ChatClientAgent : AIAgent this._chatClientType = chatClient.GetType(); // If the user has not opted out of using our default decorators, we wrap the chat client. - this.ChatClient = options?.UseProvidedChatClientAsIs is true ? chatClient : chatClient.AsAgentInvokedChatClient(options); + this.ChatClient = options?.UseProvidedChatClientAsIs is true ? chatClient : chatClient.WithDefaultAgentMiddleware(options); this._logger = (loggerFactory ?? chatClient.GetService() ?? NullLoggerFactory.Instance).CreateLogger(); } @@ -112,6 +112,10 @@ public sealed class ChatClientAgent : AIAgent (ChatClientAgentThread safeThread, ChatOptions? chatOptions, List threadMessages) = await this.PrepareThreadAndMessagesAsync(thread, inputMessages, options, cancellationToken).ConfigureAwait(false); + var chatClient = this.ChatClient; + + chatClient = ApplyRunOptionsTransformations(options, chatClient); + var agentName = this.GetLoggingAgentName(); this._logger.LogAgentChatClientInvokingAgent(nameof(RunAsync), this.Id, agentName, this._chatClientType); @@ -120,7 +124,7 @@ public sealed class ChatClientAgent : AIAgent ChatResponse chatResponse; try { - chatResponse = await this.ChatClient.GetResponseAsync(threadMessages, chatOptions, cancellationToken).ConfigureAwait(false); + chatResponse = await chatClient.GetResponseAsync(threadMessages, chatOptions, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -151,6 +155,30 @@ public sealed class ChatClientAgent : AIAgent return new(chatResponse) { AgentId = this.Id }; } + /// + /// Configures the specified instance based on the provided run options and chat options. + /// + /// This method applies transformations and customizations to the chat client and chat options + /// based on the provided . If no applicable options are provided, the original is returned unchanged. + /// The run options to apply. If is of type , + /// additional configuration such as tool transformations and custom chat client creation may be applied. + /// The instance to configure. If a custom chat client factory is provided in , a new instance may be created. + /// The configured instance. If a custom chat client factory is used, the returned + /// instance may differ from the input . + private static IChatClient ApplyRunOptionsTransformations(AgentRunOptions? options, IChatClient chatClient) + { + if (options is ChatClientAgentRunOptions agentChatOptions && agentChatOptions.ChatClientFactory is not null) + { + // If we have a custom chat client factory, we should use it to create a new chat client with the transformed tools. + chatClient = agentChatOptions.ChatClientFactory(chatClient); + _ = Throw.IfNull(chatClient); + } + + return chatClient; + } + /// public override async IAsyncEnumerable RunStreamingAsync( IEnumerable messages, @@ -164,6 +192,11 @@ public sealed class ChatClientAgent : AIAgent await this.PrepareThreadAndMessagesAsync(thread, inputMessages, options, cancellationToken).ConfigureAwait(false); int messageCount = threadMessages.Count; + + var chatClient = this.ChatClient; + + chatClient = ApplyRunOptionsTransformations(options, chatClient); + var loggingAgentName = this.GetLoggingAgentName(); this._logger.LogAgentChatClientInvokingAgent(nameof(RunStreamingAsync), this.Id, loggingAgentName, this._chatClientType); @@ -175,7 +208,7 @@ public sealed class ChatClientAgent : AIAgent try { // Using the enumerator to ensure we consider the case where no updates are returned for notification. - responseUpdatesEnumerator = this.ChatClient.GetStreamingResponseAsync(threadMessages, chatOptions, cancellationToken).GetAsyncEnumerator(cancellationToken); + responseUpdatesEnumerator = chatClient.GetStreamingResponseAsync(threadMessages, chatOptions, cancellationToken).GetAsyncEnumerator(cancellationToken); } catch (Exception ex) { @@ -237,8 +270,8 @@ public sealed class ChatClientAgent : AIAgent /// public override object? GetService(Type serviceType, object? serviceKey = null) => - base.GetService(serviceType, serviceKey) - ?? (serviceType == typeof(AIAgentMetadata) ? this._agentMetadata + base.GetService(serviceType, serviceKey) ?? + (serviceType == typeof(AIAgentMetadata) ? this._agentMetadata : serviceType == typeof(IChatClient) ? this.ChatClient : this.ChatClient.GetService(serviceType, serviceKey)); diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentRunOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentRunOptions.cs index 7389ae3737..30e69f5b76 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentRunOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentRunOptions.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI; @@ -20,4 +21,9 @@ public sealed class ChatClientAgentRunOptions : AgentRunOptions /// Gets or sets optional chat options to pass to the agent's invocation. public ChatOptions? ChatOptions { get; set; } + + /// + /// Gets or sets the factory method used to modify instances of per-request. + /// + public Func? ChatClientFactory { get; set; } } diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs index a40cae4cb1..80d3347d1f 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs @@ -10,16 +10,10 @@ namespace Microsoft.Extensions.AI; internal static class ChatClientExtensions { - internal static IChatClient AsAgentInvokedChatClient(this IChatClient chatClient, ChatClientAgentOptions? options) + internal static IChatClient WithDefaultAgentMiddleware(this IChatClient chatClient, ChatClientAgentOptions? options) { var chatBuilder = chatClient.AsBuilder(); - // AgentInvokingChatClient should be the outermost decorator - if (chatClient is not AgentInvokedChatClient agentInvokingChatClient) - { - chatBuilder.UseAgentInvocation(); - } - if (chatClient.GetService() is null) { _ = chatBuilder.Use((innerClient, services) => diff --git a/dotnet/src/Microsoft.Agents.AI/FunctionInvocationDelegatingAgent.cs b/dotnet/src/Microsoft.Agents.AI/FunctionInvocationDelegatingAgent.cs new file mode 100644 index 0000000000..daaa9fa6e7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/FunctionInvocationDelegatingAgent.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI; + +/// +/// Internal agent decorator that adds function invocation middleware logic. +/// +internal sealed class FunctionInvocationDelegatingAgent : DelegatingAIAgent +{ + private readonly Func>, CancellationToken, ValueTask> _delegateFunc; + + internal FunctionInvocationDelegatingAgent(AIAgent innerAgent, Func>, CancellationToken, ValueTask> delegateFunc) : base(innerAgent) + { + this._delegateFunc = delegateFunc; + } + + public override Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + => this.InnerAgent.RunAsync(messages, thread, this.AgentRunOptionsWithFunctionMiddleware(options), cancellationToken); + + public override IAsyncEnumerable RunStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + => this.InnerAgent.RunStreamingAsync(messages, thread, this.AgentRunOptionsWithFunctionMiddleware(options), cancellationToken); + + // Decorate options to add the middleware function + private AgentRunOptions? AgentRunOptionsWithFunctionMiddleware(AgentRunOptions? options) + { + if (options is ChatClientAgentRunOptions aco) + { + var originalFactory = aco.ChatClientFactory; + aco.ChatClientFactory = (IChatClient chatClient) => + { + var builder = chatClient.AsBuilder(); + + if (originalFactory is not null) + { + builder.Use(originalFactory); + } + + return builder.ConfigureOptions(co + => co.Tools = co.Tools?.Select(tool => tool is AIFunction aiFunction + ? aiFunction is ApprovalRequiredAIFunction approvalRequiredAiFunction + ? new ApprovalRequiredAIFunction(new MiddlewareEnabledFunction(this, approvalRequiredAiFunction, this._delegateFunc)) + : new MiddlewareEnabledFunction(this.InnerAgent, aiFunction, this._delegateFunc) + : tool) + .ToList()) + .Build(); + }; + } + + return options; + } + + private sealed class MiddlewareEnabledFunction(AIAgent innerAgent, AIFunction innerFunction, Func>, CancellationToken, ValueTask> next) : DelegatingAIFunction(innerFunction) + { + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + var context = FunctionInvokingChatClient.CurrentContext + ?? new FunctionInvocationContext() // When there is no ambient context, create a new one to hold the arguments + { + Arguments = arguments, + Function = this.InnerFunction, + CallContent = new(string.Empty, this.InnerFunction.Name, new Dictionary(arguments)), + Iteration = 0, // Indicate this function was not invoked by a FICC and has no iteration flow. + }; + + return await next(innerAgent, context, CoreLogicAsync, cancellationToken).ConfigureAwait(false); + + ValueTask CoreLogicAsync(FunctionInvocationContext ctx, CancellationToken cancellationToken) + => base.InvokeCoreAsync(ctx.Arguments, cancellationToken); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj index 724350cff5..bad037af2b 100644 --- a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj +++ b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj @@ -18,6 +18,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAIAgentBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAIAgentBuilderExtensions.cs new file mode 100644 index 0000000000..59b652ffe6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAIAgentBuilderExtensions.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// Provides extensions for configuring instances. +public static class OpenTelemetryAIAgentBuilderExtensions +{ + /// + /// Adds OpenTelemetry support to the agent pipeline for agent runs, following the OpenTelemetry Semantic Conventions for Generative AI systems. + /// + /// + /// The draft specification this follows is available at . + /// The specification is still experimental and subject to change; as such, the telemetry output by this agent is also subject to change. + /// + /// The . + /// An optional to use to create a logger for logging events. + /// An optional source name that will be used on the telemetry data. + /// An optional callback that can be used to configure the instance. + /// The . + public static AIAgentBuilder UseOpenTelemetry( + this AIAgentBuilder builder, + ILoggerFactory? loggerFactory = null, + string? sourceName = null, + Action? configure = null) => + Throw.IfNull(builder).Use((innerAgent, services) => + { + loggerFactory ??= services.GetService(); + + var agent = new OpenTelemetryAgent(innerAgent, loggerFactory?.CreateLogger(typeof(OpenTelemetryAgent)), sourceName); + configure?.Invoke(agent); + + return agent; + }); +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/AzureAgentProvider.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/AzureAgentProvider.cs index 5ad8bb9120..de031a0fd2 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/AzureAgentProvider.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/AzureAgentProvider.cs @@ -90,7 +90,7 @@ public sealed class AzureAgentProvider(string projectEndpoint, TokenCredential p /// public override async Task GetAgentAsync(string agentId, CancellationToken cancellationToken = default) => - await this.GetAgentsClient().GetAIAgentAsync(agentId, chatOptions: null, cancellationToken).ConfigureAwait(false); + await this.GetAgentsClient().GetAIAgentAsync(agentId, chatOptions: null, cancellationToken: cancellationToken).ConfigureAwait(false); /// public override async Task GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default) diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Extensions/PersistentAgentsClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Extensions/PersistentAgentsClientExtensionsTests.cs new file mode 100644 index 0000000000..bf2fdcf85d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Extensions/PersistentAgentsClientExtensionsTests.cs @@ -0,0 +1,403 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Azure; +using Azure.AI.Agents.Persistent; +using Azure.Core; +using Moq; + +namespace Microsoft.Extensions.AI.Agents.AzureAI.UnitTests.Extensions; + +public sealed class PersistentAgentsClientExtensionsTests +{ + /// + /// Verify that GetAIAgent throws ArgumentNullException when client is null. + /// + [Fact] + public void GetAIAgent_WithNullClient_ThrowsArgumentNullException() + { + // Act & Assert + var exception = Assert.Throws(() => + ((PersistentAgentsClient)null!).GetAIAgent("test-agent")); + + Assert.Equal("persistentAgentsClient", exception.ParamName); + } + + /// + /// Verify that GetAIAgent throws ArgumentException when agentId is null or whitespace. + /// + [Fact] + public void GetAIAgent_WithNullOrWhitespaceAgentId_ThrowsArgumentException() + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert - null agentId + var exception1 = Assert.Throws(() => + mockClient.Object.GetAIAgent(null!)); + Assert.Equal("agentId", exception1.ParamName); + + // Act & Assert - empty agentId + var exception2 = Assert.Throws(() => + mockClient.Object.GetAIAgent("")); + Assert.Equal("agentId", exception2.ParamName); + + // Act & Assert - whitespace agentId + var exception3 = Assert.Throws(() => + mockClient.Object.GetAIAgent(" ")); + Assert.Equal("agentId", exception3.ParamName); + } + + /// + /// Verify that GetAIAgentAsync throws ArgumentNullException when client is null. + /// + [Fact] + public async Task GetAIAgentAsync_WithNullClient_ThrowsArgumentNullExceptionAsync() + { + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + ((PersistentAgentsClient)null!).GetAIAgentAsync("test-agent")); + + Assert.Equal("persistentAgentsClient", exception.ParamName); + } + + /// + /// Verify that GetAIAgentAsync throws ArgumentException when agentId is null or whitespace. + /// + [Fact] + public async Task GetAIAgentAsync_WithNullOrWhitespaceAgentId_ThrowsArgumentExceptionAsync() + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert - null agentId + var exception1 = await Assert.ThrowsAsync(() => + mockClient.Object.GetAIAgentAsync(null!)); + Assert.Equal("agentId", exception1.ParamName); + + // Act & Assert - empty agentId + var exception2 = await Assert.ThrowsAsync(() => + mockClient.Object.GetAIAgentAsync("")); + Assert.Equal("agentId", exception2.ParamName); + + // Act & Assert - whitespace agentId + var exception3 = await Assert.ThrowsAsync(() => + mockClient.Object.GetAIAgentAsync(" ")); + Assert.Equal("agentId", exception3.ParamName); + } + + /// + /// Verify that CreateAIAgent throws ArgumentNullException when client is null. + /// + [Fact] + public void CreateAIAgent_WithNullClient_ThrowsArgumentNullException() + { + // Act & Assert + var exception = Assert.Throws(() => + ((PersistentAgentsClient)null!).CreateAIAgent("test-model")); + + Assert.Equal("persistentAgentsClient", exception.ParamName); + } + + /// + /// Verify that CreateAIAgentAsync throws ArgumentNullException when client is null. + /// + [Fact] + public async Task CreateAIAgentAsync_WithNullClient_ThrowsArgumentNullExceptionAsync() + { + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + ((PersistentAgentsClient)null!).CreateAIAgentAsync("test-model")); + + Assert.Equal("persistentAgentsClient", exception.ParamName); + } + + /// + /// Verify that AsNewIChatClient throws ArgumentNullException when client is null. + /// + [Fact] + public void AsNewIChatClient_WithNullClient_ThrowsArgumentNullException() + { + // Act & Assert + var exception = Assert.Throws(() => + ((PersistentAgentsClient)null!).AsNewIChatClient("test-agent")); + + Assert.Equal("client", exception.ParamName); + } + + /// + /// Verify that AsNewIChatClient throws ArgumentException when assistantId is null or empty. + /// + [Fact] + public void AsNewIChatClient_WithNullOrEmptyAssistantId_ThrowsArgumentException() + { + // Arrange + var mockClient = new Mock(); + + // Act & Assert - null assistantId throws ArgumentNullException + var exception1 = Assert.Throws(() => + mockClient.Object.AsNewIChatClient(null!)); + Assert.Equal("assistantId", exception1.ParamName); + + // Act & Assert - empty assistantId throws ArgumentException + var exception2 = Assert.Throws(() => + mockClient.Object.AsNewIChatClient("")); + Assert.Equal("assistantId", exception2.ParamName); + } + + /// + /// Verify that GetAIAgent with clientFactory parameter correctly applies the factory. + /// + [Fact] + public void GetAIAgent_WithClientFactory_AppliesFactoryCorrectly() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + TestChatClient? testChatClient = null; + + // Act + var agent = client.GetAIAgent( + agentId: "test-agent-id", + clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient)); + + // Assert + Assert.NotNull(agent); + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that GetAIAgent without clientFactory works normally. + /// + [Fact] + public void GetAIAgent_WithoutClientFactory_WorksNormally() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + + // Act + var agent = client.GetAIAgent(agentId: "test-agent-id"); + + // Assert + Assert.NotNull(agent); + var retrievedTestClient = agent.GetService(); + Assert.Null(retrievedTestClient); + } + + /// + /// Verify that GetAIAgent with null clientFactory works normally. + /// + [Fact] + public void GetAIAgent_WithNullClientFactory_WorksNormally() + { + // Arrange + PersistentAgentsClient client = CreateFakePersistentAgentsClient(); + + // Act + var agent = client.GetAIAgent(agentId: "test-agent-id", clientFactory: null); + + // Assert + Assert.NotNull(agent); + var retrievedTestClient = agent.GetService(); + Assert.Null(retrievedTestClient); + } + + /// + /// Verify that CreateAIAgent with clientFactory parameter correctly applies the factory. + /// + [Fact] + public void CreateAIAgent_WithClientFactory_AppliesFactoryCorrectly() + { + // Arrange + // Arrange + var client = CreateFakePersistentAgentsClient(); + TestChatClient? testChatClient = null; + + // Act + var agent = client.CreateAIAgent( + model: "test-model", + clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient)); + + // Assert + Assert.NotNull(agent); + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that CreateAIAgentAsync with clientFactory parameter correctly applies the factory. + /// + [Fact] + public async Task CreateAIAgentAsync_WithClientFactory_AppliesFactoryCorrectlyAsync() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + TestChatClient? testChatClient = null; + + // Act + var agent = await client.CreateAIAgentAsync( + model: "test-model", + clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient)); + + // Assert + Assert.NotNull(agent); + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that CreateAIAgent without clientFactory works normally. + /// + [Fact] + public void CreateAIAgent_WithoutClientFactory_WorksNormally() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + + // Act + var agent = client.CreateAIAgent(model: "test-model"); + + // Assert + Assert.NotNull(agent); + var retrievedTestClient = agent.GetService(); + Assert.Null(retrievedTestClient); + } + + /// + /// Verify that CreateAIAgent with null clientFactory works normally. + /// + [Fact] + public void CreateAIAgent_WithNullClientFactory_WorksNormally() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + + // Act + var agent = client.CreateAIAgent(model: "test-model", clientFactory: null); + + // Assert + Assert.NotNull(agent); + var retrievedTestClient = agent.GetService(); + Assert.Null(retrievedTestClient); + } + + /// + /// Verify that CreateAIAgent without clientFactory works normally. + /// + [Fact] + public async Task CreateAIAgentAsync_WithoutClientFactory_WorksNormallyAsync() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + + // Act + var agent = await client.CreateAIAgentAsync(model: "test-model"); + + // Assert + Assert.NotNull(agent); + var retrievedTestClient = agent.GetService(); + Assert.Null(retrievedTestClient); + } + + /// + /// Verify that CreateAIAgent with null clientFactory works normally. + /// + [Fact] + public async Task CreateAIAgentAsync_WithNullClientFactory_WorksNormallyAsync() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + + // Act + var agent = await client.CreateAIAgentAsync(model: "test-model", clientFactory: null); + + // Assert + Assert.NotNull(agent); + var retrievedTestClient = agent.GetService(); + Assert.Null(retrievedTestClient); + } + + /// + /// Test custom chat client that can be used to verify clientFactory functionality. + /// + private sealed class TestChatClient : DelegatingChatClient + { + public TestChatClient(IChatClient innerClient) : base(innerClient) + { + } + } + + public sealed class FakePersistentAgentsAdministrationClient : PersistentAgentsAdministrationClient + { + public FakePersistentAgentsAdministrationClient() + { + } + + public override async Task> CreateAgentAsync(string model, string? name = null, string? description = null, string? instructions = null, IEnumerable? tools = null, ToolResources? toolResources = null, float? temperature = null, float? topP = null, BinaryData? responseFormat = null, IReadOnlyDictionary? metadata = null, CancellationToken cancellationToken = default) + => await Task.FromResult(this.FakeResponse); + + public override Response CreateAgent(string model, string? name = null, string? description = null, string? instructions = null, IEnumerable? tools = null, ToolResources? toolResources = null, float? temperature = null, float? topP = null, BinaryData? responseFormat = null, IReadOnlyDictionary? metadata = null, CancellationToken cancellationToken = default) + => this.FakeResponse; + + public override Response GetAgent(string assistantId, CancellationToken cancellationToken = default) + => this.FakeResponse; + + public override async Task> GetAgentAsync(string assistantId, CancellationToken cancellationToken = default) + => await Task.FromResult(this.FakeResponse); + + private Response FakeResponse => Response.FromValue(ModelReaderWriter.Read(BinaryData.FromString("""{"id": "agent_abc123"}""")), new FakeResponse())!; + } + + private static PersistentAgentsClient CreateFakePersistentAgentsClient() + { + var client = new PersistentAgentsClient("https://any.com", DelegatedTokenCredential.Create((_, _) => new AccessToken())); + + ((System.Reflection.TypeInfo)typeof(PersistentAgentsClient)).DeclaredFields.First(f => f.Name == "_client") + .SetValue(client, new FakePersistentAgentsAdministrationClient()); + return client; + } + + private sealed class FakeResponse : Response + { + public override int Status => throw new NotImplementedException(); + + public override string ReasonPhrase => throw new NotImplementedException(); + + public override Stream? ContentStream { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + public override string ClientRequestId { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + + public override void Dispose() + { + throw new NotImplementedException(); + } + + protected override bool ContainsHeader(string name) + { + throw new NotImplementedException(); + } + + protected override IEnumerable EnumerateHeaders() + { + throw new NotImplementedException(); + } + + protected override bool TryGetHeader(string name, out string value) + { + throw new NotImplementedException(); + } + + protected override bool TryGetHeaderValues(string name, out IEnumerable values) + { + throw new NotImplementedException(); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj new file mode 100644 index 0000000000..a96251098b --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj @@ -0,0 +1,11 @@ + + + + $(ProjectsTargetFrameworks) + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIAssistantClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIAssistantClientExtensionsTests.cs new file mode 100644 index 0000000000..f90e82c31d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIAssistantClientExtensionsTests.cs @@ -0,0 +1,261 @@ +// Copyright (c) Microsoft. All rights reserved. + +#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using OpenAI; +using OpenAI.Assistants; + +namespace Microsoft.Agents.AI.OpenAI.UnitTests.Extensions; + +/// +/// Unit tests for the class. +/// +public sealed class OpenAIAssistantClientExtensionsTests +{ + /// + /// Verify that CreateAIAgent with clientFactory parameter correctly applies the factory. + /// + [Fact] + public void CreateAIAgent_WithClientFactory_AppliesFactoryCorrectly() + { + // Arrange + var assistantClient = new TestAssistantClient(); + var testChatClient = new TestChatClient(assistantClient.AsIChatClient("test-model")); + const string ModelId = "test-model"; + + // Act + var agent = assistantClient.CreateAIAgent( + ModelId, + instructions: "Test instructions", + name: "Test Agent", + description: "Test description", + clientFactory: (innerClient) => testChatClient); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + Assert.Equal("Test description", agent.Description); + + // Verify that the custom chat client can be retrieved from the agent's service collection + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that CreateAIAgent with clientFactory using AsBuilder pattern works correctly. + /// + [Fact] + public void CreateAIAgent_WithClientFactoryUsingAsBuilder_AppliesFactoryCorrectly() + { + // Arrange + var assistantClient = new TestAssistantClient(); + TestChatClient? testChatClient = null; + + const string ModelId = "test-model"; + + // Act + var agent = assistantClient.CreateAIAgent( + ModelId, + instructions: "Test instructions", + clientFactory: (innerClient) => + innerClient.AsBuilder() + .Use((innerClient) => testChatClient = new TestChatClient(innerClient)) + .Build()); + + // Assert + Assert.NotNull(agent); + + // Verify that the custom chat client can be retrieved from the agent's service collection + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that CreateAIAgent with options and clientFactory parameter correctly applies the factory. + /// + [Fact] + public void CreateAIAgent_WithOptionsAndClientFactory_AppliesFactoryCorrectly() + { + // Arrange + var assistantClient = new TestAssistantClient(); + var testChatClient = new TestChatClient(assistantClient.AsIChatClient("test-model")); + const string ModelId = "test-model"; + var options = new ChatClientAgentOptions + { + Name = "Test Agent", + Description = "Test description", + Instructions = "Test instructions" + }; + + // Act + var agent = assistantClient.CreateAIAgent( + ModelId, + options, + clientFactory: (innerClient) => testChatClient); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + Assert.Equal("Test description", agent.Description); + + // Verify that the custom chat client can be retrieved from the agent's service collection + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that CreateAIAgent without clientFactory works normally. + /// + [Fact] + public void CreateAIAgent_WithoutClientFactory_WorksNormally() + { + // Arrange + var assistantClient = new TestAssistantClient(); + const string ModelId = "test-model"; + + // Act + var agent = assistantClient.CreateAIAgent( + ModelId, + instructions: "Test instructions", + name: "Test Agent"); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + + // Verify that no TestChatClient is available since no factory was provided + var retrievedTestClient = agent.GetService(); + Assert.Null(retrievedTestClient); + } + + /// + /// Verify that CreateAIAgent with null clientFactory works normally. + /// + [Fact] + public void CreateAIAgent_WithNullClientFactory_WorksNormally() + { + // Arrange + var assistantClient = new TestAssistantClient(); + const string ModelId = "test-model"; + + // Act + var agent = assistantClient.CreateAIAgent( + ModelId, + instructions: "Test instructions", + name: "Test Agent", + clientFactory: null); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + + // Verify that no TestChatClient is available since no factory was provided + var retrievedTestClient = agent.GetService(); + Assert.Null(retrievedTestClient); + } + + /// + /// Verify that CreateAIAgent throws ArgumentNullException when client is null. + /// + [Fact] + public void CreateAIAgent_WithNullClient_ThrowsArgumentNullException() + { + // Act & Assert + var exception = Assert.Throws(() => + ((AssistantClient)null!).CreateAIAgent("test-model")); + + Assert.Equal("client", exception.ParamName); + } + + /// + /// Verify that CreateAIAgent throws ArgumentNullException when model is null. + /// + [Fact] + public void CreateAIAgent_WithNullModel_ThrowsArgumentNullException() + { + // Arrange + var assistantClient = new TestAssistantClient(); + + // Act & Assert + var exception = Assert.Throws(() => + assistantClient.CreateAIAgent(null!)); + + Assert.Equal("model", exception.ParamName); + } + + /// + /// Verify that CreateAIAgent with options throws ArgumentNullException when options is null. + /// + [Fact] + public void CreateAIAgent_WithNullOptions_ThrowsArgumentNullException() + { + // Arrange + var assistantClient = new TestAssistantClient(); + + // Act & Assert + var exception = Assert.Throws(() => + assistantClient.CreateAIAgent("test-model", (ChatClientAgentOptions)null!)); + + Assert.Equal("options", exception.ParamName); + } + + /// + /// Creates a test AssistantClient implementation for testing. + /// + private sealed class TestAssistantClient : AssistantClient + { + public TestAssistantClient() + { + } + + public override ClientResult CreateAssistant(string model, AssistantCreationOptions? options = null, CancellationToken cancellationToken = default) + { + return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString("""{"id": "asst_abc123"}""")), new FakePipelineResponse())!; + } + } + + private sealed class TestChatClient : DelegatingChatClient + { + public TestChatClient(IChatClient innerClient) : base(innerClient) + { + } + } + + private sealed class FakePipelineResponse : PipelineResponse + { + public override int Status => throw new NotImplementedException(); + + public override string ReasonPhrase => throw new NotImplementedException(); + + public override Stream? ContentStream { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + + public override BinaryData Content => throw new NotImplementedException(); + + protected override PipelineResponseHeaders HeadersCore => throw new NotImplementedException(); + + public override BinaryData BufferContent(CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public override ValueTask BufferContentAsync(CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public override void Dispose() + { + throw new NotImplementedException(); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIChatClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIChatClientExtensionsTests.cs new file mode 100644 index 0000000000..72ea0395e9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIChatClientExtensionsTests.cs @@ -0,0 +1,227 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using OpenAI; +using OpenAIChatClient = OpenAI.Chat.ChatClient; + +namespace Microsoft.Agents.AI.OpenAI.UnitTests.Extensions; + +/// +/// Unit tests for the class. +/// +public sealed class OpenAIChatClientExtensionsTests +{ + /// + /// Test custom chat client that can be used to verify clientFactory functionality. + /// + private sealed class TestChatClient : IChatClient + { + private readonly IChatClient _innerClient; + + public TestChatClient(IChatClient innerClient) + { + this._innerClient = innerClient; + } + + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => this._innerClient.GetResponseAsync(messages, options, cancellationToken); + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (var update in this._innerClient.GetStreamingResponseAsync(messages, options, cancellationToken)) + { + yield return update; + } + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + // Return this instance when requested + if (serviceType == typeof(TestChatClient)) + { + return this; + } + + return this._innerClient.GetService(serviceType, serviceKey); + } + + public void Dispose() => this._innerClient.Dispose(); + } + + /// + /// Creates a test ChatClient implementation for testing. + /// + private sealed class TestOpenAIChatClient : OpenAIChatClient + { + public TestOpenAIChatClient() + { + } + } + + /// + /// Verify that CreateAIAgent with clientFactory parameter correctly applies the factory. + /// + [Fact] + public void CreateAIAgent_WithClientFactory_AppliesFactoryCorrectly() + { + // Arrange + var chatClient = new TestOpenAIChatClient(); + var testChatClient = new TestChatClient(chatClient.AsIChatClient()); + + // Act + var agent = chatClient.CreateAIAgent( + instructions: "Test instructions", + name: "Test Agent", + description: "Test description", + clientFactory: (innerClient) => testChatClient); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + Assert.Equal("Test description", agent.Description); + + // Verify that the custom chat client can be retrieved from the agent's service collection + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that CreateAIAgent with clientFactory using AsBuilder pattern works correctly. + /// + [Fact] + public void CreateAIAgent_WithClientFactoryUsingAsBuilder_AppliesFactoryCorrectly() + { + // Arrange + var chatClient = new TestOpenAIChatClient(); + TestChatClient? testChatClient = null; + + // Act + var agent = chatClient.CreateAIAgent( + instructions: "Test instructions", + clientFactory: (innerClient) => + innerClient.AsBuilder().Use((innerClient) => testChatClient = new TestChatClient(innerClient)).Build()); + + // Assert + Assert.NotNull(agent); + + // Verify that the custom chat client can be retrieved from the agent's service collection + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that CreateAIAgent with options and clientFactory parameter correctly applies the factory. + /// + [Fact] + public void CreateAIAgent_WithOptionsAndClientFactory_AppliesFactoryCorrectly() + { + // Arrange + var chatClient = new TestOpenAIChatClient(); + var testChatClient = new TestChatClient(chatClient.AsIChatClient()); + var options = new ChatClientAgentOptions + { + Name = "Test Agent", + Description = "Test description", + Instructions = "Test instructions" + }; + + // Act + var agent = chatClient.CreateAIAgent( + options, + clientFactory: (innerClient) => testChatClient); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + Assert.Equal("Test description", agent.Description); + + // Verify that the custom chat client can be retrieved from the agent's service collection + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that CreateAIAgent without clientFactory works normally. + /// + [Fact] + public void CreateAIAgent_WithoutClientFactory_WorksNormally() + { + // Arrange + var chatClient = new TestOpenAIChatClient(); + + // Act + var agent = chatClient.CreateAIAgent( + instructions: "Test instructions", + name: "Test Agent"); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + + // Verify that no TestChatClient is available since no factory was provided + var retrievedTestClient = agent.GetService(); + Assert.Null(retrievedTestClient); + } + + /// + /// Verify that CreateAIAgent with null clientFactory works normally. + /// + [Fact] + public void CreateAIAgent_WithNullClientFactory_WorksNormally() + { + // Arrange + var chatClient = new TestOpenAIChatClient(); + + // Act + var agent = chatClient.CreateAIAgent( + instructions: "Test instructions", + name: "Test Agent", + clientFactory: null); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + + // Verify that no TestChatClient is available since no factory was provided + var retrievedTestClient = agent.GetService(); + Assert.Null(retrievedTestClient); + } + + /// + /// Verify that CreateAIAgent throws ArgumentNullException when client is null. + /// + [Fact] + public void CreateAIAgent_WithNullClient_ThrowsArgumentNullException() + { + // Act & Assert + var exception = Assert.Throws(() => + ((OpenAIChatClient)null!).CreateAIAgent()); + + Assert.Equal("client", exception.ParamName); + } + + /// + /// Verify that CreateAIAgent with options throws ArgumentNullException when options is null. + /// + [Fact] + public void CreateAIAgent_WithNullOptions_ThrowsArgumentNullException() + { + // Arrange + var chatClient = new TestOpenAIChatClient(); + + // Act & Assert + var exception = Assert.Throws(() => + chatClient.CreateAIAgent((ChatClientAgentOptions)null!)); + + Assert.Equal("options", exception.ParamName); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIResponseClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIResponseClientExtensionsTests.cs new file mode 100644 index 0000000000..a2899a1717 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIResponseClientExtensionsTests.cs @@ -0,0 +1,172 @@ +// Copyright (c) Microsoft. All rights reserved. + +#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using OpenAI; +using OpenAI.Responses; + +namespace Microsoft.Agents.AI.OpenAI.UnitTests.Extensions; + +/// +/// Unit tests for the class. +/// +public sealed class OpenAIResponseClientExtensionsTests +{ + /// + /// Test custom chat client that can be used to verify clientFactory functionality. + /// + private sealed class TestChatClient : IChatClient + { + private readonly IChatClient _innerClient; + + public TestChatClient(IChatClient innerClient) + { + this._innerClient = innerClient; + } + + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => this._innerClient.GetResponseAsync(messages, options, cancellationToken); + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (var update in this._innerClient.GetStreamingResponseAsync(messages, options, cancellationToken)) + { + yield return update; + } + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + // Return this instance when requested + if (serviceType == typeof(TestChatClient)) + { + return this; + } + + return this._innerClient.GetService(serviceType, serviceKey); + } + + public void Dispose() => this._innerClient.Dispose(); + } + + /// + /// Creates a test OpenAIResponseClient implementation for testing. + /// + private sealed class TestOpenAIResponseClient : OpenAIResponseClient + { + public TestOpenAIResponseClient() + { + } + } + + /// + /// Verify that CreateAIAgent with clientFactory parameter correctly applies the factory. + /// + [Fact] + public void CreateAIAgent_WithClientFactory_AppliesFactoryCorrectly() + { + // Arrange + var responseClient = new TestOpenAIResponseClient(); + var testChatClient = new TestChatClient(responseClient.AsIChatClient()); + + // Act + var agent = responseClient.CreateAIAgent( + instructions: "Test instructions", + name: "Test Agent", + description: "Test description", + clientFactory: (innerClient) => testChatClient); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + Assert.Equal("Test description", agent.Description); + + // Verify that the custom chat client can be retrieved from the agent's service collection + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that CreateAIAgent without clientFactory works normally. + /// + [Fact] + public void CreateAIAgent_WithoutClientFactory_WorksNormally() + { + // Arrange + var responseClient = new TestOpenAIResponseClient(); + + // Act + var agent = responseClient.CreateAIAgent( + instructions: "Test instructions", + name: "Test Agent"); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + + // Verify that no TestChatClient is available since no factory was provided + var retrievedTestClient = agent.GetService(); + Assert.Null(retrievedTestClient); + } + + /// + /// Verify that CreateAIAgent with null clientFactory works normally. + /// + [Fact] + public void CreateAIAgent_WithNullClientFactory_WorksNormally() + { + // Arrange + var responseClient = new TestOpenAIResponseClient(); + + // Act + var agent = responseClient.CreateAIAgent( + instructions: "Test instructions", + name: "Test Agent", + clientFactory: null); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + + // Verify that no TestChatClient is available since no factory was provided + var retrievedTestClient = agent.GetService(); + Assert.Null(retrievedTestClient); + } + + /// + /// Verify that CreateAIAgent throws ArgumentNullException when client is null. + /// + [Fact] + public void CreateAIAgent_WithNullClient_ThrowsArgumentNullException() + { + // Act & Assert + var exception = Assert.Throws(() => + ((OpenAIResponseClient)null!).CreateAIAgent()); + + Assert.Equal("client", exception.ParamName); + } + + /// + /// Verify that CreateAIAgent with options throws ArgumentNullException when options is null. + /// + [Fact] + public void CreateAIAgent_WithNullOptions_ThrowsArgumentNullException() + { + // Arrange + var responseClient = new TestOpenAIResponseClient(); + + // Act & Assert + var exception = Assert.Throws(() => + responseClient.CreateAIAgent((ChatClientAgentOptions)null!)); + + Assert.Equal("options", exception.ParamName); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj new file mode 100644 index 0000000000..7f26fdc132 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj @@ -0,0 +1,11 @@ + + + + $(ProjectsTargetFrameworks) + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIAgentBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIAgentBuilderTests.cs new file mode 100644 index 0000000000..ca5803bba4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AIAgentBuilderTests.cs @@ -0,0 +1,437 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Unit tests for the class. +/// +public class AIAgentBuilderTests +{ + /// + /// Verify that constructor throws ArgumentNullException when innerAgent is null. + /// + [Fact] + public void Constructor_WithNullInnerAgent_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws("innerAgent", () => new AIAgentBuilder((AIAgent)null!)); + } + + /// + /// Verify that constructor throws ArgumentNullException when innerAgentFactory is null. + /// + [Fact] + public void Constructor_WithNullInnerAgentFactory_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws("innerAgentFactory", () => new AIAgentBuilder((Func)null!)); + } + + /// + /// Verify that Build returns the inner agent when no middleware is added. + /// + [Fact] + public void Build_WithNoMiddleware_ReturnsInnerAgent() + { + // Arrange + var mockAgent = new Mock(); + var builder = new AIAgentBuilder(mockAgent.Object); + + // Act + var result = builder.Build(); + + // Assert + Assert.Same(mockAgent.Object, result); + } + + /// + /// Verify that Build works with factory function. + /// + [Fact] + public void Build_WithFactory_ReturnsAgentFromFactory() + { + // Arrange + var mockAgent = new Mock(); + var builder = new AIAgentBuilder(_ => mockAgent.Object); + + // Act + var result = builder.Build(); + + // Assert + Assert.Same(mockAgent.Object, result); + } + + /// + /// Verify that Use with simple factory works correctly. + /// + [Fact] + public void Use_WithSimpleFactory_AppliesMiddleware() + { + // Arrange + var mockInnerAgent = new Mock(); + var mockOuterAgent = new Mock(); + var builder = new AIAgentBuilder(mockInnerAgent.Object); + + // Act + var result = builder.Use(innerAgent => + { + Assert.Same(mockInnerAgent.Object, innerAgent); + return mockOuterAgent.Object; + }).Build(); + + // Assert + Assert.Same(mockOuterAgent.Object, result); + } + + /// + /// Verify that Use with service provider factory works correctly. + /// + [Fact] + public void Use_WithServiceProviderFactory_AppliesMiddleware() + { + // Arrange + var mockInnerAgent = new Mock(); + var mockOuterAgent = new Mock(); + var mockServiceProvider = new Mock(); + var builder = new AIAgentBuilder(mockInnerAgent.Object); + + // Act + var result = builder.Use((innerAgent, services) => + { + Assert.Same(mockInnerAgent.Object, innerAgent); + Assert.NotNull(services); + return mockOuterAgent.Object; + }).Build(mockServiceProvider.Object); + + // Assert + Assert.Same(mockOuterAgent.Object, result); + } + + /// + /// Verify that multiple middleware are applied in correct order (first added is outermost). + /// + [Fact] + public void Use_WithMultipleMiddleware_AppliesInCorrectOrder() + { + // Arrange + var mockInnerAgent = new Mock(); + var mockMiddleAgent = new Mock(); + var mockOuterAgent = new Mock(); + var builder = new AIAgentBuilder(mockInnerAgent.Object); + + // Act + var result = builder + .Use(innerAgent => + { + // First middleware added (will be outermost) - should receive result of second middleware + Assert.Same(mockMiddleAgent.Object, innerAgent); + return mockOuterAgent.Object; + }) + .Use(innerAgent => + { + // Second middleware added (will be applied first) - should receive the original inner agent + Assert.Same(mockInnerAgent.Object, innerAgent); + return mockMiddleAgent.Object; + }) + .Build(); + + // Assert + // The result should be from the first middleware since it's the outermost + Assert.Same(mockOuterAgent.Object, result); + } + + /// + /// Verify that Use throws ArgumentNullException when agentFactory is null. + /// + [Fact] + public void Use_WithNullSimpleFactory_ThrowsArgumentNullException() + { + // Arrange + var mockAgent = new Mock(); + var builder = new AIAgentBuilder(mockAgent.Object); + + // Act & Assert + Assert.Throws("agentFactory", () => builder.Use((Func)null!)); + } + + /// + /// Verify that Use throws ArgumentNullException when agentFactory with service provider is null. + /// + [Fact] + public void Use_WithNullServiceProviderFactory_ThrowsArgumentNullException() + { + // Arrange + var mockAgent = new Mock(); + var builder = new AIAgentBuilder(mockAgent.Object); + + // Act & Assert + Assert.Throws("agentFactory", () => builder.Use((Func)null!)); + } + + /// + /// Verify that Build throws InvalidOperationException when middleware returns null. + /// + [Fact] + public void Build_WithMiddlewareReturningNull_ThrowsInvalidOperationException() + { + // Arrange + var mockAgent = new Mock(); + var builder = new AIAgentBuilder(mockAgent.Object); + + // Act & Assert + var exception = Assert.Throws(() => + builder.Use(_ => null!).Build()); + + Assert.Contains("returned null", exception.Message); + Assert.Contains("AIAgentBuilder", exception.Message); + } + + /// + /// Verify that Build uses EmptyServiceProvider when services is null. + /// + [Fact] + public void Build_WithNullServices_UsesEmptyServiceProvider() + { + // Arrange + var mockAgent = new Mock(); + var builder = new AIAgentBuilder(mockAgent.Object); + IServiceProvider? capturedServices = null; + + // Act + builder.Use((agent, services) => + { + capturedServices = services; + return agent; + }).Build(null); + + // Assert + Assert.NotNull(capturedServices); + Assert.Null(capturedServices.GetService(typeof(string))); // EmptyServiceProvider returns null for everything + } + + /// + /// Verify that service provider is passed correctly to factories. + /// + [Fact] + public void PassesServiceProviderToFactories() + { + // Arrange + var expectedServiceProvider = new ServiceCollection().BuildServiceProvider(); + var mockInnerAgent = new Mock(); + var mockOuterAgent = new Mock(); + + var builder = new AIAgentBuilder(services => + { + Assert.Same(expectedServiceProvider, services); + return mockInnerAgent.Object; + }); + + builder.Use((innerAgent, serviceProvider) => + { + Assert.Same(expectedServiceProvider, serviceProvider); + Assert.Same(mockInnerAgent.Object, innerAgent); + return mockOuterAgent.Object; + }); + + // Act + var result = builder.Build(expectedServiceProvider); + + // Assert + Assert.Same(mockOuterAgent.Object, result); + } + + /// + /// Verify that pipeline is built in the order added (first added is outermost). + /// + [Fact] + public void BuildsPipelineInOrderAdded() + { + // Arrange + var mockInnerAgent = new Mock(); + var builder = new AIAgentBuilder(mockInnerAgent.Object); + + builder.Use(next => new InnerAgentCapturingAgent("First", next)); + builder.Use(next => new InnerAgentCapturingAgent("Second", next)); + builder.Use(next => new InnerAgentCapturingAgent("Third", next)); + + // Act + var first = (InnerAgentCapturingAgent)builder.Build(); + + // Assert + Assert.Equal("First", first.TestName); + var second = (InnerAgentCapturingAgent)first.InnerAgent; + Assert.Equal("Second", second.TestName); + var third = (InnerAgentCapturingAgent)second.InnerAgent; + Assert.Equal("Third", third.TestName); + Assert.Same(mockInnerAgent.Object, third.InnerAgent); + } + + /// + /// Verify that factories cannot return null. + /// + [Fact] + public void DoesNotAllowFactoriesToReturnNull() + { + // Arrange + var mockAgent = new Mock(); + var builder = new AIAgentBuilder(mockAgent.Object); + builder.Use(_ => null!); + + // Act & Assert + var ex = Assert.Throws(() => builder.Build()); + Assert.Contains("entry at index 0", ex.Message); + } + + /// + /// Verify that EmptyServiceProvider is used when no services are provided and supports keyed services. + /// + [Fact] + public void UsesEmptyServiceProviderWhenNoServicesProvided() + { + // Arrange + var mockAgent = new Mock(); + var builder = new AIAgentBuilder(mockAgent.Object); + + // Act & Assert + builder.Use((innerAgent, serviceProvider) => + { + Assert.Null(serviceProvider.GetService(typeof(object))); + + var keyedServiceProvider = Assert.IsAssignableFrom(serviceProvider); + Assert.Null(keyedServiceProvider.GetKeyedService(typeof(object), "key")); + Assert.Throws(() => keyedServiceProvider.GetRequiredKeyedService(typeof(object), "key")); + + return innerAgent; + }); + builder.Build(); + } + + #region Delegate Overload Tests + + /// + /// Verify that Use with shared delegate throws ArgumentNullException when sharedFunc is null. + /// + [Fact] + public void Use_WithNullSharedFunc_ThrowsArgumentNullException() + { + // Arrange + var mockAgent = new Mock(); + var builder = new AIAgentBuilder(mockAgent.Object); + + // Act & Assert + Assert.Throws("sharedFunc", () => + builder.Use((Func, AgentThread?, AgentRunOptions?, Func, AgentThread?, AgentRunOptions?, CancellationToken, Task>, CancellationToken, Task>)null!)); + } + + /// + /// Verify that Use with both delegates null throws ArgumentNullException. + /// + [Fact] + public void Use_WithBothDelegatesNull_ThrowsArgumentNullException() + { + // Arrange + var mockAgent = new Mock(); + var builder = new AIAgentBuilder(mockAgent.Object); + + // Act & Assert + var exception = Assert.Throws(() => + builder.Use(null, null)); + + Assert.Contains("runFunc", exception.Message); + } + + /// + /// Verify that Use with shared delegate creates AnonymousDelegatingAIAgent. + /// + [Fact] + public void Use_WithSharedDelegate_CreatesAnonymousDelegatingAgent() + { + // Arrange + var mockAgent = new Mock(); + var builder = new AIAgentBuilder(mockAgent.Object); + + // Act + var result = builder.Use((_, _, _, _, _) => Task.CompletedTask).Build(); + + // Assert + Assert.IsType(result); + } + + /// + /// Verify that Use with runFunc only creates AnonymousDelegatingAIAgent. + /// + [Fact] + public void Use_WithRunFuncOnly_CreatesAnonymousDelegatingAgent() + { + // Arrange + var mockAgent = new Mock(); + var builder = new AIAgentBuilder(mockAgent.Object); + + // Act + var result = builder.Use((_, _, _, _, _) => Task.FromResult(new AgentRunResponse()), null).Build(); + + // Assert + Assert.IsType(result); + } + + /// + /// Verify that Use with runStreamingFunc only creates AnonymousDelegatingAIAgent. + /// + [Fact] + public void Use_WithStreamingFuncOnly_CreatesAnonymousDelegatingAgent() + { + // Arrange + var mockAgent = new Mock(); + var builder = new AIAgentBuilder(mockAgent.Object); + + // Act + var result = builder.Use(null, (_, _, _, _, _) => AsyncEnumerable.Empty()).Build(); + + // Assert + Assert.IsType(result); + } + + /// + /// Verify that Use with both delegates creates AnonymousDelegatingAIAgent. + /// + [Fact] + public void Use_WithBothDelegates_CreatesAnonymousDelegatingAgent() + { + // Arrange + var mockAgent = new Mock(); + var builder = new AIAgentBuilder(mockAgent.Object); + + // Act + var result = builder.Use( + (_, _, _, _, _) => Task.FromResult(new AgentRunResponse()), + (_, _, _, _, _) => AsyncEnumerable.Empty()).Build(); + + // Assert + Assert.IsType(result); + } + + #endregion + + /// + /// Helper class for testing pipeline order. + /// + private sealed class InnerAgentCapturingAgent : DelegatingAIAgent + { + public string TestName { get; } + public new AIAgent InnerAgent => base.InnerAgent; + + public InnerAgentCapturingAgent(string name, AIAgent innerAgent) : base(innerAgent) + { + this.TestName = name; + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AnonymousDelegatingAIAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AnonymousDelegatingAIAgentTests.cs new file mode 100644 index 0000000000..369ab1ad4f --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AnonymousDelegatingAIAgentTests.cs @@ -0,0 +1,1017 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Unit tests for the class. +/// +public class AnonymousDelegatingAIAgentTests +{ + private readonly Mock _innerAgentMock; + private readonly List _testMessages; + private readonly AgentThread _testThread; + private readonly AgentRunOptions _testOptions; + private readonly AgentRunResponse _testResponse; + private readonly AgentRunResponseUpdate[] _testStreamingResponses; + + public AnonymousDelegatingAIAgentTests() + { + this._innerAgentMock = new Mock(); + this._testMessages = [new ChatMessage(ChatRole.User, "Test message")]; + this._testThread = new Mock().Object; + this._testOptions = new AgentRunOptions(); + this._testResponse = new AgentRunResponse([new ChatMessage(ChatRole.Assistant, "Test response")]); + this._testStreamingResponses = [ + new AgentRunResponseUpdate(ChatRole.Assistant, "Response 1"), + new AgentRunResponseUpdate(ChatRole.Assistant, "Response 2") + ]; + + this._innerAgentMock.Setup(x => x.RunAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(this._testResponse); + + this._innerAgentMock.Setup(x => x.RunStreamingAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(ToAsyncEnumerableAsync(this._testStreamingResponses)); + } + + #region Constructor Tests + + /// + /// Verify that constructor throws ArgumentNullException when innerAgent is null. + /// + [Fact] + public void Constructor_WithNullInnerAgent_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws("innerAgent", () => + new AnonymousDelegatingAIAgent(null!, (_, _, _, _, _) => Task.CompletedTask)); + } + + /// + /// Verify that constructor throws ArgumentNullException when sharedFunc is null. + /// + [Fact] + public void Constructor_WithNullSharedFunc_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws("sharedFunc", () => + new AnonymousDelegatingAIAgent(this._innerAgentMock.Object, null!)); + } + + /// + /// Verify that constructor throws ArgumentNullException when both delegates are null. + /// + [Fact] + public void Constructor_WithBothDelegatesNull_ThrowsArgumentNullException() + { + // Act & Assert + var exception = Assert.Throws(() => + new AnonymousDelegatingAIAgent(this._innerAgentMock.Object, null, null)); + + Assert.Contains("runFunc", exception.Message); + } + + /// + /// Verify that constructor succeeds with valid sharedFunc. + /// + [Fact] + public void Constructor_WithValidSharedFunc_Succeeds() + { + // Act + var agent = new AnonymousDelegatingAIAgent(this._innerAgentMock.Object, (_, _, _, _, _) => Task.CompletedTask); + + // Assert + Assert.NotNull(agent); + } + + /// + /// Verify that constructor succeeds with valid runFunc only. + /// + [Fact] + public void Constructor_WithValidRunFunc_Succeeds() + { + // Act + var agent = new AnonymousDelegatingAIAgent( + this._innerAgentMock.Object, + (_, _, _, _, _) => Task.FromResult(this._testResponse), + null); + + // Assert + Assert.NotNull(agent); + } + + /// + /// Verify that constructor succeeds with valid runStreamingFunc only. + /// + [Fact] + public void Constructor_WithValidRunStreamingFunc_Succeeds() + { + // Act + var agent = new AnonymousDelegatingAIAgent( + this._innerAgentMock.Object, + null, + (_, _, _, _, _) => ToAsyncEnumerableAsync(this._testStreamingResponses)); + + // Assert + Assert.NotNull(agent); + } + + /// + /// Verify that constructor succeeds with both runFunc and runStreamingFunc. + /// + [Fact] + public void Constructor_WithBothRunAndStreamingFunc_Succeeds() + { + // Act + var agent = new AnonymousDelegatingAIAgent( + this._innerAgentMock.Object, + (_, _, _, _, _) => Task.FromResult(this._testResponse), + (_, _, _, _, _) => ToAsyncEnumerableAsync(this._testStreamingResponses)); + + // Assert + Assert.NotNull(agent); + } + + #endregion + + #region Shared Function Tests + + /// + /// Verify that shared function receives correct context and calls inner agent. + /// + [Fact] + public async Task RunAsync_WithSharedFunc_ContextPropagatedAsync() + { + // Arrange + IEnumerable? capturedMessages = null; + AgentThread? capturedThread = null; + AgentRunOptions? capturedOptions = null; + CancellationToken capturedCancellationToken = default; + var expectedCancellationToken = new CancellationToken(true); + + var agent = new AnonymousDelegatingAIAgent(this._innerAgentMock.Object, + async (messages, thread, options, next, cancellationToken) => + { + capturedMessages = messages; + capturedThread = thread; + capturedOptions = options; + capturedCancellationToken = cancellationToken; + await next(messages, thread, options, cancellationToken); + }); + + // Act + await agent.RunAsync(this._testMessages, this._testThread, this._testOptions, expectedCancellationToken); + + // Assert + Assert.Same(this._testMessages, capturedMessages); + Assert.Same(this._testThread, capturedThread); + Assert.Same(this._testOptions, capturedOptions); + Assert.Equal(expectedCancellationToken, capturedCancellationToken); + + this._innerAgentMock.Verify(x => x.RunAsync( + this._testMessages, + this._testThread, + this._testOptions, + expectedCancellationToken), Times.Once); + } + + /// + /// Verify that shared function works for both RunAsync and RunStreamingAsync. + /// + [Fact] + public async Task SharedFunc_WorksForBothRunAndStreamingAsync() + { + // Arrange + var callCount = 0; + var agent = new AnonymousDelegatingAIAgent(this._innerAgentMock.Object, + async (messages, thread, options, next, cancellationToken) => + { + callCount++; + await next(messages, thread, options, cancellationToken); + }); + + // Act + await agent.RunAsync(this._testMessages, this._testThread, this._testOptions); + var streamingResults = await agent.RunStreamingAsync(this._testMessages, this._testThread, this._testOptions).ToListAsync(); + + // Assert + Assert.Equal(2, callCount); + Assert.NotNull(streamingResults); + Assert.Equal(this._testStreamingResponses.Length, streamingResults.Count); + } + + #endregion + + #region Separate Delegate Tests + + /// + /// Verify that RunAsync with runFunc only uses the runFunc. + /// + [Fact] + public async Task RunAsync_WithRunFuncOnly_UsesRunFuncAsync() + { + // Arrange + var runFuncCalled = false; + var agent = new AnonymousDelegatingAIAgent( + this._innerAgentMock.Object, + (messages, thread, options, innerAgent, cancellationToken) => + { + runFuncCalled = true; + return innerAgent.RunAsync(messages, thread, options, cancellationToken); + }, + null); + + // Act + var result = await agent.RunAsync(this._testMessages, this._testThread, this._testOptions); + + // Assert + Assert.True(runFuncCalled); + Assert.Same(this._testResponse, result); + } + + /// + /// Verify that RunStreamingAsync with runFunc only converts from runFunc. + /// + [Fact] + public async Task RunStreamingAsync_WithRunFuncOnly_ConvertsFromRunFuncAsync() + { + // Arrange + var runFuncCalled = false; + var agent = new AnonymousDelegatingAIAgent( + this._innerAgentMock.Object, + (messages, thread, options, innerAgent, cancellationToken) => + { + runFuncCalled = true; + return innerAgent.RunAsync(messages, thread, options, cancellationToken); + }, + null); + + // Act + var results = await agent.RunStreamingAsync(this._testMessages, this._testThread, this._testOptions).ToListAsync(); + + // Assert + Assert.True(runFuncCalled); + Assert.NotEmpty(results); + } + + /// + /// Verify that RunAsync with runStreamingFunc only converts from runStreamingFunc. + /// + [Fact] + public async Task RunAsync_WithStreamingFuncOnly_ConvertsFromStreamingFuncAsync() + { + // Arrange + var streamingFuncCalled = false; + var agent = new AnonymousDelegatingAIAgent( + this._innerAgentMock.Object, + null, + (messages, thread, options, innerAgent, cancellationToken) => + { + streamingFuncCalled = true; + return innerAgent.RunStreamingAsync(messages, thread, options, cancellationToken); + }); + + // Act + var result = await agent.RunAsync(this._testMessages, this._testThread, this._testOptions); + + // Assert + Assert.True(streamingFuncCalled); + Assert.NotNull(result); + } + + /// + /// Verify that RunStreamingAsync with runStreamingFunc only uses the runStreamingFunc. + /// + [Fact] + public async Task RunStreamingAsync_WithStreamingFuncOnly_UsesStreamingFuncAsync() + { + // Arrange + var streamingFuncCalled = false; + var agent = new AnonymousDelegatingAIAgent( + this._innerAgentMock.Object, + null, + (messages, thread, options, innerAgent, cancellationToken) => + { + streamingFuncCalled = true; + return innerAgent.RunStreamingAsync(messages, thread, options, cancellationToken); + }); + + // Act + var results = await agent.RunStreamingAsync(this._testMessages, this._testThread, this._testOptions).ToListAsync(); + + // Assert + Assert.True(streamingFuncCalled); + Assert.Equal(this._testStreamingResponses.Length, results.Count); + } + + /// + /// Verify that when both delegates are provided, each uses its respective implementation. + /// + [Fact] + public async Task BothDelegates_EachUsesRespectiveImplementationAsync() + { + // Arrange + var runFuncCalled = false; + var streamingFuncCalled = false; + + var agent = new AnonymousDelegatingAIAgent( + this._innerAgentMock.Object, + (messages, thread, options, innerAgent, cancellationToken) => + { + runFuncCalled = true; + return innerAgent.RunAsync(messages, thread, options, cancellationToken); + }, + (messages, thread, options, innerAgent, cancellationToken) => + { + streamingFuncCalled = true; + return innerAgent.RunStreamingAsync(messages, thread, options, cancellationToken); + }); + + // Act + await agent.RunAsync(this._testMessages, this._testThread, this._testOptions); + await agent.RunStreamingAsync(this._testMessages, this._testThread, this._testOptions).ToListAsync(); + + // Assert + Assert.True(runFuncCalled); + Assert.True(streamingFuncCalled); + } + + #endregion + + #region Error Handling Tests + + /// + /// Verify that exceptions from shared function are propagated. + /// + [Fact] + public async Task SharedFunc_ThrowsException_PropagatesExceptionAsync() + { + // Arrange + var expectedException = new InvalidOperationException("Test exception"); + var agent = new AnonymousDelegatingAIAgent(this._innerAgentMock.Object, + (_, _, _, _, _) => throw expectedException); + + // Act & Assert + var actualException = await Assert.ThrowsAsync( + () => agent.RunAsync(this._testMessages, this._testThread, this._testOptions)); + + Assert.Same(expectedException, actualException); + } + + /// + /// Verify that exceptions from runFunc are propagated. + /// + [Fact] + public async Task RunFunc_ThrowsException_PropagatesExceptionAsync() + { + // Arrange + var expectedException = new InvalidOperationException("Test exception"); + var agent = new AnonymousDelegatingAIAgent( + this._innerAgentMock.Object, + (_, _, _, _, _) => throw expectedException, + null); + + // Act & Assert + var actualException = await Assert.ThrowsAsync( + () => agent.RunAsync(this._testMessages, this._testThread, this._testOptions)); + + Assert.Same(expectedException, actualException); + } + + /// + /// Verify that exceptions from runStreamingFunc are propagated. + /// + [Fact] + public async Task StreamingFunc_ThrowsException_PropagatesExceptionAsync() + { + // Arrange + var expectedException = new InvalidOperationException("Test exception"); + var agent = new AnonymousDelegatingAIAgent( + this._innerAgentMock.Object, + null, + (_, _, _, _, _) => throw expectedException); + + // Act & Assert + var actualException = await Assert.ThrowsAsync(async () => + { + await foreach (var _ in agent.RunStreamingAsync(this._testMessages, this._testThread, this._testOptions)) + { + // Should throw before yielding any items + } + }); + + Assert.Same(expectedException, actualException); + } + + /// + /// Verify that shared function that doesn't call inner agent throws InvalidOperationException. + /// + [Fact] + public async Task SharedFunc_DoesNotCallInner_ThrowsInvalidOperationAsync() + { + // Arrange + var agent = new AnonymousDelegatingAIAgent(this._innerAgentMock.Object, + (_, _, _, _, _) => Task.CompletedTask); // Doesn't call next + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => agent.RunAsync(this._testMessages, this._testThread, this._testOptions)); + + Assert.Contains("without producing an AgentRunResponse", exception.Message); + } + + #endregion + + #region AsyncLocal Context Tests + + /// + /// Verify that AsyncLocal context is maintained across delegate boundaries. + /// + [Fact] + public async Task AsyncLocalContext_MaintainedAcrossDelegatesAsync() + { + // Arrange + var asyncLocal = new AsyncLocal(); + var capturedValue = 0; + + var agent = new AnonymousDelegatingAIAgent(this._innerAgentMock.Object, + async (messages, thread, options, next, cancellationToken) => + { + asyncLocal.Value = 42; + await next(messages, thread, options, cancellationToken); + capturedValue = asyncLocal.Value; + }); + + this._innerAgentMock.Setup(x => x.RunAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(() => + { + // Verify AsyncLocal value is available in inner agent call + Assert.Equal(42, asyncLocal.Value); + return Task.FromResult(this._testResponse); + }); + + // Act + Assert.Equal(0, asyncLocal.Value); // Initial value + await agent.RunAsync(this._testMessages, this._testThread, this._testOptions); + + // Assert + Assert.Equal(0, asyncLocal.Value); // Should be reset after call + Assert.Equal(42, capturedValue); // But was maintained during call + } + + #endregion + + #region Multiple Middleware Chaining Tests + + /// + /// Verify that multiple middleware execute in correct order (outer-to-inner, then inner-to-outer). + /// + [Fact] + public async Task MultipleMiddleware_ExecuteInCorrectOrderAsync() + { + // Arrange + var executionOrder = new List(); + + var outerAgent = new AnonymousDelegatingAIAgent(this._innerAgentMock.Object, + async (messages, thread, options, next, cancellationToken) => + { + executionOrder.Add("Outer-Pre"); + await next(messages, thread, options, cancellationToken); + executionOrder.Add("Outer-Post"); + }); + + var middleAgent = new AnonymousDelegatingAIAgent(outerAgent, + async (messages, thread, options, next, cancellationToken) => + { + executionOrder.Add("Middle-Pre"); + await next(messages, thread, options, cancellationToken); + executionOrder.Add("Middle-Post"); + }); + + var innerAgent = new AnonymousDelegatingAIAgent(middleAgent, + async (messages, thread, options, next, cancellationToken) => + { + executionOrder.Add("Inner-Pre"); + await next(messages, thread, options, cancellationToken); + executionOrder.Add("Inner-Post"); + }); + + // Act + await innerAgent.RunAsync(this._testMessages, this._testThread, this._testOptions); + + // Assert + var expectedOrder = new[] { "Inner-Pre", "Middle-Pre", "Outer-Pre", "Outer-Post", "Middle-Post", "Inner-Post" }; + Assert.Equal(expectedOrder, executionOrder); + } + + /// + /// Verify that multiple middleware with separate delegates execute in correct order. + /// + [Fact] + public async Task MultipleMiddleware_SeparateDelegates_ExecuteInCorrectOrderAsync() + { + // Arrange + var executionOrder = new List(); + + var outerAgent = new AnonymousDelegatingAIAgent(this._innerAgentMock.Object, + (messages, thread, options, innerAgent, cancellationToken) => + { + executionOrder.Add("Outer-Run"); + return innerAgent.RunAsync(messages, thread, options, cancellationToken); + }, + (messages, thread, options, innerAgent, cancellationToken) => + { + executionOrder.Add("Outer-Streaming"); + return innerAgent.RunStreamingAsync(messages, thread, options, cancellationToken); + }); + + var middleAgent = new AnonymousDelegatingAIAgent(outerAgent, + (messages, thread, options, innerAgent, cancellationToken) => + { + executionOrder.Add("Middle-Run"); + return innerAgent.RunAsync(messages, thread, options, cancellationToken); + }, + (messages, thread, options, innerAgent, cancellationToken) => + { + executionOrder.Add("Middle-Streaming"); + return innerAgent.RunStreamingAsync(messages, thread, options, cancellationToken); + }); + + // Act + await middleAgent.RunAsync(this._testMessages, this._testThread, this._testOptions); + await middleAgent.RunStreamingAsync(this._testMessages, this._testThread, this._testOptions).ToListAsync(); + + // Assert + Assert.Contains("Middle-Run", executionOrder); + Assert.Contains("Outer-Run", executionOrder); + Assert.Contains("Middle-Streaming", executionOrder); + Assert.Contains("Outer-Streaming", executionOrder); + + var runIndex = executionOrder.IndexOf("Middle-Run"); + var outerRunIndex = executionOrder.IndexOf("Outer-Run"); + var streamingIndex = executionOrder.IndexOf("Middle-Streaming"); + var outerStreamingIndex = executionOrder.IndexOf("Outer-Streaming"); + + Assert.True(runIndex < outerRunIndex); + Assert.True(streamingIndex < outerStreamingIndex); + } + + /// + /// Verify that middleware can capture and modify parameters during execution. + /// + [Fact] + public async Task MultipleMiddleware_ContextModification_PropagatedAsync() + { + // Arrange + var capturedOptions = new List(); + var executionOrder = new List(); + + var outerAgent = new AnonymousDelegatingAIAgent(this._innerAgentMock.Object, + async (messages, thread, options, next, cancellationToken) => + { + executionOrder.Add("Outer-Pre"); + await next(messages, thread, options, cancellationToken); + executionOrder.Add("Outer-Post"); + }); + + var innerAgent = new AnonymousDelegatingAIAgent(outerAgent, + async (messages, thread, options, next, cancellationToken) => + { + executionOrder.Add("Inner-Pre"); + capturedOptions.Add(options); + await next(messages, thread, options, cancellationToken); + executionOrder.Add("Inner-Post"); + }); + + // Act + await innerAgent.RunAsync(this._testMessages, this._testThread, this._testOptions); + + // Assert + Assert.Single(capturedOptions); + Assert.Same(this._testOptions, capturedOptions[0]); // Inner middleware sees original options + var expectedOrder = new[] { "Inner-Pre", "Outer-Pre", "Outer-Post", "Inner-Post" }; + Assert.Equal(expectedOrder, executionOrder); + } + + #endregion + + #region Error Handling in Chains Tests + + /// + /// Verify that exceptions in middleware chains are properly propagated. + /// + [Fact] + public async Task MultipleMiddleware_ExceptionInMiddle_PropagatesAsync() + { + // Arrange + var expectedException = new InvalidOperationException("Middle middleware error"); + var outerExecuted = false; + var innerExecuted = false; + + var outerAgent = new AnonymousDelegatingAIAgent(this._innerAgentMock.Object, + async (messages, thread, options, next, cancellationToken) => + { + outerExecuted = true; + await next(messages, thread, options, cancellationToken); + }); + + var middleAgent = new AnonymousDelegatingAIAgent(outerAgent, + (_, _, _, _, _) => throw expectedException); + + var innerAgent = new AnonymousDelegatingAIAgent(middleAgent, + async (messages, thread, options, next, cancellationToken) => + { + innerExecuted = true; + await next(messages, thread, options, cancellationToken); + }); + + // Act & Assert + var actualException = await Assert.ThrowsAsync( + () => innerAgent.RunAsync(this._testMessages, this._testThread, this._testOptions)); + + Assert.Same(expectedException, actualException); + Assert.True(innerExecuted); // Inner middleware should execute + Assert.False(outerExecuted); // Outer middleware should not execute due to exception + } + + /// + /// Verify that exceptions in streaming middleware chains are properly propagated. + /// + [Fact] + public async Task MultipleMiddleware_ExceptionInStreaming_PropagatesAsync() + { + // Arrange + var expectedException = new InvalidOperationException("Streaming middleware error"); + + var outerAgent = new AnonymousDelegatingAIAgent(this._innerAgentMock.Object, + null, + (_, _, _, _, _) => throw expectedException); + + var innerAgent = new AnonymousDelegatingAIAgent(outerAgent, + null, + (messages, thread, options, innerAgent, cancellationToken) => + innerAgent.RunStreamingAsync(messages, thread, options, cancellationToken)); + + // Act & Assert + var actualException = await Assert.ThrowsAsync(async () => + { + await foreach (var _ in innerAgent.RunStreamingAsync(this._testMessages, this._testThread, this._testOptions)) + { + // Should throw before yielding any items + } + }); + + Assert.Same(expectedException, actualException); + } + + #endregion + + #region Multiple Middleware Chaining Tests + + /// + /// Verify that multiple middleware using AIAgentBuilder.Use() execute in correct order. + /// + [Fact] + public async Task AIAgentBuilder_Use_MultipleMiddleware_ExecutesInCorrectOrderAsync() + { + // Arrange + var executionOrder = new List(); + + var agent = new AIAgentBuilder(this._innerAgentMock.Object) + .Use(async (messages, thread, options, next, cancellationToken) => + { + executionOrder.Add("First-Pre"); + await next(messages, thread, options, cancellationToken); + executionOrder.Add("First-Post"); + }) + .Use(async (messages, thread, options, next, cancellationToken) => + { + executionOrder.Add("Second-Pre"); + await next(messages, thread, options, cancellationToken); + executionOrder.Add("Second-Post"); + }) + .Build(); + + // Act + await agent.RunAsync(this._testMessages, this._testThread, this._testOptions); + + // Assert + var expectedOrder = new[] { "First-Pre", "Second-Pre", "Second-Post", "First-Post" }; + Assert.Equal(expectedOrder, executionOrder); + } + + /// + /// Verify that multiple middleware with separate run/streaming delegates execute correctly. + /// + [Fact] + public async Task AIAgentBuilder_Use_MultipleMiddlewareWithSeparateDelegates_ExecutesCorrectlyAsync() + { + // Arrange + var runExecutionOrder = new List(); + var streamingExecutionOrder = new List(); + + static async IAsyncEnumerable FirstStreamingMiddlewareAsync( + IEnumerable messages, AgentThread? thread, AgentRunOptions? options, AIAgent innerAgent, + [EnumeratorCancellation] CancellationToken cancellationToken, + List executionOrder) + { + executionOrder.Add("First-Streaming-Pre"); + await foreach (var update in innerAgent.RunStreamingAsync(messages, thread, options, cancellationToken)) + { + yield return update; + } + executionOrder.Add("First-Streaming-Post"); + } + + static async IAsyncEnumerable SecondStreamingMiddlewareAsync( + IEnumerable messages, AgentThread? thread, AgentRunOptions? options, AIAgent innerAgent, + [EnumeratorCancellation] CancellationToken cancellationToken, + List executionOrder) + { + executionOrder.Add("Second-Streaming-Pre"); + await foreach (var update in innerAgent.RunStreamingAsync(messages, thread, options, cancellationToken)) + { + yield return update; + } + executionOrder.Add("Second-Streaming-Post"); + } + + var agent = new AIAgentBuilder(this._innerAgentMock.Object) + .Use( + async (messages, thread, options, innerAgent, cancellationToken) => + { + runExecutionOrder.Add("First-Run-Pre"); + var result = await innerAgent.RunAsync(messages, thread, options, cancellationToken); + runExecutionOrder.Add("First-Run-Post"); + return result; + }, + (messages, thread, options, innerAgent, cancellationToken) => + FirstStreamingMiddlewareAsync(messages, thread, options, innerAgent, cancellationToken, streamingExecutionOrder)) + .Use( + async (messages, thread, options, innerAgent, cancellationToken) => + { + runExecutionOrder.Add("Second-Run-Pre"); + var result = await innerAgent.RunAsync(messages, thread, options, cancellationToken); + runExecutionOrder.Add("Second-Run-Post"); + return result; + }, + (messages, thread, options, innerAgent, cancellationToken) => + SecondStreamingMiddlewareAsync(messages, thread, options, innerAgent, cancellationToken, streamingExecutionOrder)) + .Build(); + + // Act + await agent.RunAsync(this._testMessages, this._testThread, this._testOptions); + await agent.RunStreamingAsync(this._testMessages, this._testThread, this._testOptions).ToListAsync(); + + // Assert + var expectedRunOrder = new[] { "First-Run-Pre", "Second-Run-Pre", "Second-Run-Post", "First-Run-Post" }; + var expectedStreamingOrder = new[] { "First-Streaming-Pre", "Second-Streaming-Pre", "Second-Streaming-Post", "First-Streaming-Post" }; + + Assert.Equal(expectedRunOrder, runExecutionOrder); + Assert.Equal(expectedStreamingOrder, streamingExecutionOrder); + } + + /// + /// Verify that middleware can modify messages and options before passing to next middleware. + /// + [Fact] + public async Task AIAgentBuilder_Use_MiddlewareModifiesContext_ChangesPropagateAsync() + { + // Arrange + IEnumerable? capturedMessages = null; + AgentRunOptions? capturedOptions = null; + + var agent = new AIAgentBuilder(this._innerAgentMock.Object) + .Use(async (messages, thread, options, next, cancellationToken) => + { + // Modify messages and options + var modifiedMessages = messages.Concat([new ChatMessage(ChatRole.System, "Added by first middleware")]); + var modifiedOptions = new AgentRunOptions(); + await next(modifiedMessages, thread, modifiedOptions, cancellationToken); + }) + .Use(async (messages, thread, options, next, cancellationToken) => + { + // Capture what the second middleware receives + capturedMessages = messages; + capturedOptions = options; + await next(messages, thread, options, cancellationToken); + }) + .Build(); + + // Act + await agent.RunAsync(this._testMessages, this._testThread, this._testOptions); + + // Assert + Assert.NotNull(capturedMessages); + Assert.NotNull(capturedOptions); + Assert.Equal(2, capturedMessages.Count()); // Original + added message + Assert.Contains(capturedMessages, m => m.Text == "Added by first middleware"); + } + + #endregion + + #region Error Handling in Chains Tests + + /// + /// Verify that exceptions in middleware chains are properly propagated. + /// + [Fact] + public async Task AIAgentBuilder_Use_ExceptionInMiddlewareChain_PropagatesCorrectlyAsync() + { + // Arrange + var expectedException = new InvalidOperationException("Test exception from middleware"); + var executionOrder = new List(); + + var agent = new AIAgentBuilder(this._innerAgentMock.Object) + .Use(async (messages, thread, options, next, cancellationToken) => + { + executionOrder.Add("First-Pre"); + try + { + await next(messages, thread, options, cancellationToken); + executionOrder.Add("First-Post-Success"); + } + catch + { + executionOrder.Add("First-Post-Exception"); + throw; + } + }) + .Use(async (messages, thread, options, next, cancellationToken) => + { + executionOrder.Add("Second-Pre"); + throw expectedException; + }) + .Build(); + + // Act & Assert + var actualException = await Assert.ThrowsAsync( + () => agent.RunAsync(this._testMessages, this._testThread, this._testOptions)); + + Assert.Same(expectedException, actualException); + var expectedOrder = new[] { "First-Pre", "Second-Pre", "First-Post-Exception" }; + Assert.Equal(expectedOrder, executionOrder); + } + + /// + /// Verify that middleware can handle and recover from exceptions in the chain. + /// + [Fact] + public async Task AIAgentBuilder_Use_MiddlewareHandlesException_RecoveryWorksAsync() + { + // Arrange + var executionOrder = new List(); + var fallbackResponse = new AgentRunResponse([new ChatMessage(ChatRole.Assistant, "Fallback response")]); + + var agent = new AIAgentBuilder(this._innerAgentMock.Object) + .Use( + async (messages, thread, options, innerAgent, cancellationToken) => + { + executionOrder.Add("Handler-Pre"); + try + { + return await innerAgent.RunAsync(messages, thread, options, cancellationToken); + } + catch (InvalidOperationException) + { + executionOrder.Add("Handler-Caught-Exception"); + return fallbackResponse; + } + }, + null) + .Use(async (messages, thread, options, next, cancellationToken) => + { + executionOrder.Add("Throwing-Pre"); + throw new InvalidOperationException("Simulated error"); + }) + .Build(); + + // Act + var result = await agent.RunAsync(this._testMessages, this._testThread, this._testOptions); + + // Assert + Assert.Same(fallbackResponse, result); + var expectedOrder = new[] { "Handler-Pre", "Throwing-Pre", "Handler-Caught-Exception" }; + Assert.Equal(expectedOrder, executionOrder); + } + + /// + /// Verify that cancellation tokens are properly propagated through middleware chains. + /// + [Fact] + public async Task AIAgentBuilder_Use_CancellationTokenPropagation_WorksCorrectlyAsync() + { + // Arrange + var expectedToken = new CancellationToken(true); + var capturedTokens = new List(); + + // Setup mock to throw OperationCanceledException when cancelled token is used + this._innerAgentMock.Setup(x => x.RunAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.Is(ct => ct.IsCancellationRequested))) + .ThrowsAsync(new OperationCanceledException()); + + var agent = new AIAgentBuilder(this._innerAgentMock.Object) + .Use(async (messages, thread, options, next, cancellationToken) => + { + capturedTokens.Add(cancellationToken); + await next(messages, thread, options, cancellationToken); + }) + .Use(async (messages, thread, options, next, cancellationToken) => + { + capturedTokens.Add(cancellationToken); + await next(messages, thread, options, cancellationToken); + }) + .Build(); + + // Act & Assert + await Assert.ThrowsAsync( + () => agent.RunAsync(this._testMessages, this._testThread, this._testOptions, expectedToken)); + + Assert.All(capturedTokens, token => Assert.Equal(expectedToken, token)); + Assert.Equal(2, capturedTokens.Count); + } + + /// + /// Verify that middleware can short-circuit the chain by not calling next. + /// + [Fact] + public async Task AIAgentBuilder_Use_MiddlewareShortCircuits_InnerAgentNotCalledAsync() + { + // Arrange + var shortCircuitResponse = new AgentRunResponse([new ChatMessage(ChatRole.Assistant, "Short-circuited")]); + var executionOrder = new List(); + + var agent = new AIAgentBuilder(this._innerAgentMock.Object) + .Use( + async (messages, thread, options, innerAgent, cancellationToken) => + { + executionOrder.Add("First-Pre"); + var result = await innerAgent.RunAsync(messages, thread, options, cancellationToken); + executionOrder.Add("First-Post"); + return result; + }, + null) + .Use( + async (messages, thread, options, innerAgent, cancellationToken) => + { + executionOrder.Add("Second-ShortCircuit"); + // Don't call inner agent - short circuit the chain + return shortCircuitResponse; + }, + null) + .Build(); + + // Act + var result = await agent.RunAsync(this._testMessages, this._testThread, this._testOptions); + + // Assert + Assert.Same(shortCircuitResponse, result); + var expectedOrder = new[] { "First-Pre", "Second-ShortCircuit", "First-Post" }; + Assert.Equal(expectedOrder, executionOrder); + + // Verify inner agent was never called + this._innerAgentMock.Verify(x => x.RunAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + #endregion + + #region Helper Methods + + private static async IAsyncEnumerable ToAsyncEnumerableAsync(IEnumerable items) + { + foreach (var item in items) + { + await Task.Yield(); + yield return item; + } + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentRunOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentRunOptionsTests.cs index 1dde05f78b..0c9bce2867 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentRunOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentRunOptionsTests.cs @@ -1,6 +1,12 @@ // Copyright (c) Microsoft. All rights reserved. +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Moq; namespace Microsoft.Agents.AI.UnitTests; @@ -38,4 +44,292 @@ public class ChatClientAgentRunOptionsTests Assert.Same(chatOptions, retrievedOptions); Assert.Equal(200, retrievedOptions.MaxOutputTokens); // Ensure the change is reflected } + + #region ChatClientFactory Tests + + /// + /// Tests that ChatClientFactory is called and transforms the client for RunAsync. + /// + [Fact] + public async Task RunAsync_WithChatClientFactory_UsesTransformedClientAsync() + { + // Arrange + var originalClient = new Mock(); + var transformedClient = new Mock(); + var factoryCallCount = 0; + + // Setup the original client to throw if called (should not be used) + originalClient.Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Throws(new InvalidOperationException("Original client should not be called")); + + // Setup the transformed client to return a response + transformedClient.Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Transformed response")])); + + // Create the factory that transforms the client + IChatClient ClientFactory(IChatClient client) + { + factoryCallCount++; + Assert.Same(originalClient.Object, client); // Verify original client is passed + return transformedClient.Object; + } + + var agent = new ChatClientAgent(originalClient.Object, new ChatClientAgentOptions() { UseProvidedChatClientAsIs = true }); + var messages = new List { new(ChatRole.User, "Test message") }; + var options = new ChatClientAgentRunOptions { ChatClientFactory = ClientFactory }; + + // Act + var response = await agent.RunAsync(messages, null, options, CancellationToken.None); + + // Assert + Assert.NotNull(response); + Assert.Equal(1, factoryCallCount); // Factory should be called exactly once + transformedClient.Verify(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), Times.Once); + originalClient.Verify(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + /// + /// Tests that ChatClientFactory is called and transforms the client for RunStreamingAsync. + /// + [Fact] + public async Task RunStreamingAsync_WithChatClientFactory_UsesTransformedClientAsync() + { + // Arrange + var originalClient = new Mock(); + var transformedClient = new Mock(); + var factoryCallCount = 0; + + // Setup the original client to throw if called (should not be used) + originalClient.Setup(c => c.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Throws(new InvalidOperationException("Original client should not be called")); + + // Setup the transformed client to return streaming responses + var streamingResponses = new[] + { + new ChatResponseUpdate { Contents = [new TextContent("Streaming ")] }, + new ChatResponseUpdate { Contents = [new TextContent("response")] } + }; + transformedClient.Setup(c => c.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns(streamingResponses.ToAsyncEnumerable()); + + // Create the factory that transforms the client + IChatClient ClientFactory(IChatClient client) + { + factoryCallCount++; + Assert.Same(originalClient.Object, client); // Verify original client is passed + return transformedClient.Object; + } + + var agent = new ChatClientAgent(originalClient.Object, new ChatClientAgentOptions() { UseProvidedChatClientAsIs = true }); + var messages = new List { new(ChatRole.User, "Test message") }; + var options = new ChatClientAgentRunOptions { ChatClientFactory = ClientFactory }; + + // Act + var responseUpdates = new List(); + await foreach (var update in agent.RunStreamingAsync(messages, null, options, CancellationToken.None)) + { + responseUpdates.Add(update); + } + + // Assert + Assert.NotEmpty(responseUpdates); + Assert.Equal(1, factoryCallCount); // Factory should be called exactly once + transformedClient.Verify(c => c.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), Times.Once); + originalClient.Verify(c => c.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + /// + /// Tests that without ChatClientFactory, the original client is used for RunAsync. + /// + [Fact] + public async Task RunAsync_WithoutChatClientFactory_UsesOriginalClientAsync() + { + // Arrange + var originalClient = new Mock(); + + originalClient.Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Original response")])); + + var agent = new ChatClientAgent(originalClient.Object); + var messages = new List { new(ChatRole.User, "Test message") }; + + // Act - No ChatClientFactory provided + var response = await agent.RunAsync(messages, null, null, CancellationToken.None); + + // Assert + Assert.NotNull(response); + originalClient.Verify(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), Times.Once); + } + + /// + /// Tests that without ChatClientFactory, the original client is used for RunStreamingAsync. + /// + [Fact] + public async Task RunStreamingAsync_WithoutChatClientFactory_UsesOriginalClientAsync() + { + // Arrange + var originalClient = new Mock(); + + var streamingResponses = new[] + { + new ChatResponseUpdate { Contents = [new TextContent("Original ")] }, + new ChatResponseUpdate { Contents = [new TextContent("streaming")] } + }; + originalClient.Setup(c => c.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns(streamingResponses.ToAsyncEnumerable()); + + var agent = new ChatClientAgent(originalClient.Object); + var messages = new List { new(ChatRole.User, "Test message") }; + + // Act - No ChatClientFactory provided + var responseUpdates = new List(); + await foreach (var update in agent.RunStreamingAsync(messages, null, null, CancellationToken.None)) + { + responseUpdates.Add(update); + } + + // Assert + Assert.NotEmpty(responseUpdates); + originalClient.Verify(c => c.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), Times.Once); + } + + /// + /// Tests that ChatClientFactory is called for each separate RunAsync call. + /// + [Fact] + public async Task RunAsync_MultipleCalls_ChatClientFactoryCalledEachTimeAsync() + { + // Arrange + var originalClient = new Mock(); + var transformedClient = new Mock(); + var factoryCallCount = 0; + + transformedClient.Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response")])); + + IChatClient ClientFactory(IChatClient client) + { + factoryCallCount++; + return transformedClient.Object; + } + + var agent = new ChatClientAgent(originalClient.Object); + var messages = new List { new(ChatRole.User, "Test message") }; + var options = new ChatClientAgentRunOptions { ChatClientFactory = ClientFactory }; + + // Act - Call RunAsync multiple times + await agent.RunAsync(messages, null, options, CancellationToken.None); + await agent.RunAsync(messages, null, options, CancellationToken.None); + + // Assert + Assert.Equal(2, factoryCallCount); // Factory should be called for each run + transformedClient.Verify(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), Times.Exactly(2)); + } + + /// + /// Tests that subsequent calls without ChatClientFactory use the original client. + /// + [Fact] + public async Task RunAsync_AfterFactoryCall_WithoutFactory_UsesOriginalClientAsync() + { + // Arrange + var originalClient = new Mock(); + var transformedClient = new Mock(); + + originalClient.Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Original response")])); + + transformedClient.Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Transformed response")])); + + IChatClient ClientFactory(IChatClient client) => transformedClient.Object; + + var agent = new ChatClientAgent(originalClient.Object); + var messages = new List { new(ChatRole.User, "Test message") }; + var optionsWithFactory = new ChatClientAgentRunOptions { ChatClientFactory = ClientFactory }; + + // Act - First call with factory, second call without + await agent.RunAsync(messages, null, optionsWithFactory, CancellationToken.None); + await agent.RunAsync(messages, null, null, CancellationToken.None); + + // Assert + transformedClient.Verify(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), Times.Once); + originalClient.Verify(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), Times.Once); + } + + /// + /// Tests that ChatClientFactory returning null throws an exception. + /// + [Fact] + public async Task RunAsync_ChatClientFactoryReturnsNull_ThrowsExceptionAsync() + { + // Arrange + var originalClient = new Mock(); + + IChatClient ClientFactory(IChatClient client) => null!; + + var agent = new ChatClientAgent(originalClient.Object); + var messages = new List { new(ChatRole.User, "Test message") }; + var options = new ChatClientAgentRunOptions { ChatClientFactory = ClientFactory }; + + // Act & Assert + await Assert.ThrowsAsync(async () => + await agent.RunAsync(messages, null, options, CancellationToken.None)); + } + + #endregion } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs index 7986574214..b2633c80c5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs @@ -38,7 +38,7 @@ public class ChatClientAgentTests Assert.Equal("test description", agent.Description); Assert.Equal("test instructions", agent.Instructions); Assert.NotNull(agent.ChatClient); - Assert.Equal("AgentInvokedChatClient", agent.ChatClient.GetType().Name); + Assert.Equal("FunctionInvokingChatClient", agent.ChatClient.GetType().Name); } #endregion @@ -1321,14 +1321,36 @@ public class ChatClientAgentTests }); // Act - var result = agent.GetService(typeof(IChatClient)); + var result = agent.GetService(); // Assert Assert.NotNull(result); Assert.IsType(result, exactMatch: false); // Note: The result will be the AgentInvokedChatClient wrapper, not the original mock - Assert.Equal("AgentInvokedChatClient", result.GetType().Name); + Assert.Equal("FunctionInvokingChatClient", result.GetType().Name); + } + + /// + /// Verify that GetService returns IChatClient when requested. + /// + [Fact] + public void GetService_RequestingChatClientAgent_ReturnsChatClientAgent() + { + // Arrange + var mockChatClient = new Mock(); + var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions + { + Instructions = "Test instructions" + }); + + // Act + var result = agent.GetService(); + + // Assert + Assert.NotNull(result); + + Assert.Same(result, agent); } /// diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/FunctionInvocationDelegatingAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/FunctionInvocationDelegatingAgentTests.cs new file mode 100644 index 0000000000..722e981e9c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/FunctionInvocationDelegatingAgentTests.cs @@ -0,0 +1,851 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Unit tests for FunctionCallMiddlewareAgent functionality. +/// +public sealed class FunctionInvocationDelegatingAgentTests +{ + #region Basic Functionality Tests + + /// + /// Tests that FunctionCallMiddlewareAgent can be created with valid parameters. + /// + [Fact] + public void Constructor_ValidParameters_CreatesInstance() + { + // Arrange + var mockChatClient = new Mock(); + var innerAgent = new ChatClientAgent(mockChatClient.Object); + static ValueTask CallbackAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + => next(context, cancellationToken); + + // Act + var middleware = new FunctionInvocationDelegatingAgent(innerAgent, CallbackAsync); + + // Assert + Assert.NotNull(middleware); + Assert.Equal(innerAgent.Id, middleware.Id); + Assert.Equal(innerAgent.Name, middleware.Name); + Assert.Equal(innerAgent.Description, middleware.Description); + } + + /// + /// Tests that constructor throws ArgumentNullException for null inner agent. + /// + [Fact] + public void Constructor_NullInnerAgent_ThrowsArgumentNullException() + { + // Arrange + static ValueTask CallbackAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + => next(context, cancellationToken); + + // Act & Assert + Assert.Throws(() => new FunctionInvocationDelegatingAgent(null!, CallbackAsync)); + } + #endregion + + #region Function Invocation Tests + + /// + /// Tests that middleware is invoked when functions are called during agent execution. + /// + [Fact] + public async Task RunAsync_WithFunctionCall_InvokesMiddlewareAsync() + { + // Arrange + var executionOrder = new List(); + var testFunction = AIFunctionFactory.Create(() => + { + executionOrder.Add("Function-Executed"); + return "Function result"; + }, "TestFunction", "A test function"); + + var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary()); + var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall); + + var innerAgent = new ChatClientAgent(mockChatClient.Object); + var messages = new List { new(ChatRole.User, "Test message") }; + + async ValueTask MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + { + executionOrder.Add("Middleware-Pre"); + var result = await next(context, cancellationToken); + executionOrder.Add("Middleware-Post"); + return result; + } + + var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync); + + // Act + var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] }); + await middleware.RunAsync(messages, null, options, CancellationToken.None); + + // Assert + Assert.Contains("Middleware-Pre", executionOrder); + Assert.Contains("Function-Executed", executionOrder); + Assert.Contains("Middleware-Post", executionOrder); + + // Verify execution order + var middlewarePreIndex = executionOrder.IndexOf("Middleware-Pre"); + var functionIndex = executionOrder.IndexOf("Function-Executed"); + var middlewarePostIndex = executionOrder.IndexOf("Middleware-Post"); + + Assert.True(middlewarePreIndex < functionIndex); + Assert.True(functionIndex < middlewarePostIndex); + } + + /// + /// Tests that multiple function calls trigger middleware for each invocation. + /// + [Fact] + public async Task RunAsync_WithMultipleFunctionCalls_InvokesMiddlewareForEachAsync() + { + // Arrange + var executionOrder = new List(); + var function1 = AIFunctionFactory.Create(() => + { + executionOrder.Add("Function1-Executed"); + return "Function1 result"; + }, "Function1", "First test function"); + + var function2 = AIFunctionFactory.Create(() => + { + executionOrder.Add("Function2-Executed"); + return "Function2 result"; + }, "Function2", "Second test function"); + + var functionCall1 = new FunctionCallContent("call_1", "Function1", new Dictionary()); + var functionCall2 = new FunctionCallContent("call_2", "Function2", new Dictionary()); + + var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall1, functionCall2); + var innerAgent = new ChatClientAgent(mockChatClient.Object); + var messages = new List { new(ChatRole.User, "Test message") }; + + async ValueTask MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + { + executionOrder.Add($"Middleware-Pre-{context.Function.Name}"); + var result = await next(context, cancellationToken); + executionOrder.Add($"Middleware-Post-{context.Function.Name}"); + return result; + } + + var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync); + + // Act + var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [function1, function2] }); + await middleware.RunAsync(messages, null, options, CancellationToken.None); + + // Assert + Assert.Contains("Middleware-Pre-Function1", executionOrder); + Assert.Contains("Function1-Executed", executionOrder); + Assert.Contains("Middleware-Post-Function1", executionOrder); + Assert.Contains("Middleware-Pre-Function2", executionOrder); + Assert.Contains("Function2-Executed", executionOrder); + Assert.Contains("Middleware-Post-Function2", executionOrder); + } + + #endregion + + #region Context Validation Tests + + /// + /// Tests that FunctionInvocationContext contains correct values during middleware execution. + /// + [Fact] + public async Task RunAsync_MiddlewareContext_ContainsCorrectValuesAsync() + { + // Arrange + var testFunction = AIFunctionFactory.Create(() => "Function result", "TestFunction", "A test function"); + var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary { ["param"] = "value" }); + var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall); + + var innerAgent = new ChatClientAgent(mockChatClient.Object); + var messages = new List { new(ChatRole.User, "Test message") }; + + FunctionInvocationContext? capturedContext = null; + AIAgent? capturedAgent = null; + + async ValueTask MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + { + capturedContext = context; + capturedAgent = agent; + return await next(context, cancellationToken); + } + + var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync); + + // Act + var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] }); + await middleware.RunAsync(messages, null, options, CancellationToken.None); + + // Assert + Assert.NotNull(capturedContext); + Assert.Equal("TestFunction", capturedContext.Function.Name); + Assert.Same(innerAgent, capturedAgent); // The agent passed should be the inner agent + Assert.NotNull(capturedContext.Arguments); + // Note: Additional context properties would need to be verified based on actual FunctionInvocationContext structure + } + + #endregion + + #region AIAgentBuilder Use Method Tests + + /// + /// Verify that AIAgentBuilder.Use method works correctly with function invocation middleware. + /// + [Fact] + public async Task AIAgentBuilder_Use_FunctionInvocationMiddleware_WorksCorrectlyAsync() + { + // Arrange + var mockChatClient = new Mock(); + var testFunction = AIFunctionFactory.Create(() => "test result", name: "TestFunction"); + var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary()); + var executionOrder = new List(); + + // Mock the chat client to return a function call, then a response + mockChatClient.Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, [functionCall]))); + + var innerAgent = new ChatClientAgent(mockChatClient.Object); + var messages = new List { new(ChatRole.User, "Test message") }; + + // Act + var agent = new AIAgentBuilder(innerAgent) + .Use((agent, context, next, cancellationToken) => + { + executionOrder.Add("Middleware-Pre"); + var result = next(context, cancellationToken); + executionOrder.Add("Middleware-Post"); + return result; + }) + .Build(); + + var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] }); + await agent.RunAsync(messages, null, options, CancellationToken.None); + + // Assert + Assert.Contains("Middleware-Pre", executionOrder); + Assert.Contains("Middleware-Post", executionOrder); + } + + /// + /// Verify that multiple function invocation middleware are executed. + /// + [Fact] + public async Task AIAgentBuilder_Use_MultipleFunctionMiddleware_BothExecuteAsync() + { + // Arrange + var mockChatClient = new Mock(); + var testFunction = AIFunctionFactory.Create(() => "test result", name: "TestFunction"); + var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary()); + var firstMiddlewareExecuted = false; + var secondMiddlewareExecuted = false; + + // Mock the chat client to return a function call, then a response + mockChatClient.Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, [functionCall]))); + + var innerAgent = new ChatClientAgent(mockChatClient.Object); + var messages = new List { new(ChatRole.User, "Test message") }; + + // Act + var agent = new AIAgentBuilder(innerAgent) + .Use((agent, context, next, cancellationToken) => + { + firstMiddlewareExecuted = true; + return next(context, cancellationToken); + }) + .Use((agent, context, next, cancellationToken) => + { + secondMiddlewareExecuted = true; + return next(context, cancellationToken); + }) + .Build(); + + var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] }); + await agent.RunAsync(messages, null, options, CancellationToken.None); + + // Assert + Assert.True(firstMiddlewareExecuted, "First middleware should have executed"); + Assert.True(secondMiddlewareExecuted, "Second middleware should have executed"); + } + + /// + /// Verify that AIAgentBuilder.Use method throws InvalidOperationException when inner agent is doesn't use a FunctinInvocking. + /// + [Fact] + public void AIAgentBuilder_Use_NonFICCEnabledAgent_ThrowsInvalidOperationException() + { + // Arrange + var mockAgent = new Mock(); + + // Act & Assert + var builder = new AIAgentBuilder(mockAgent.Object); + var exception = Assert.Throws(() => + { + builder.Use((agent, context, next, cancellationToken) => next(context, cancellationToken)); + builder.Build(); + }); + } + + /// + /// Verify that AIAgentBuilder.Use method throws InvalidOperationException when inner agent is doesn't use a FunctinInvokingChatClient. + /// + [Fact] + public void AIAgentBuilder_Use_NonFICCDecoratedChatClientInAgent_ThrowsInvalidOperationException() + { + // Arrange + var mockChatClient = new Mock(); + + var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions() { UseProvidedChatClientAsIs = true }); + + // Act & Assert + var builder = new AIAgentBuilder(agent); + var exception = Assert.Throws(() => + { + builder.Use((agent, context, next, cancellationToken) => next(context, cancellationToken)); + builder.Build(); + }); + } + + /// + /// Tests function invocation middleware when FunctionInvokingChatClient.CurrentContext is null (direct function invocation). + /// + [Fact] + public async Task RunAsync_DirectFunctionInvocation_MiddlewareHandlesNullCurrentContextAsync() + { + // Arrange + var executionOrder = new List(); + var capturedContext = new List(); + + var testFunction = AIFunctionFactory.Create(() => + { + executionOrder.Add("Function-Executed"); + return "Function result"; + }, "TestFunction", "A test function"); + + var mockChatClient = new Mock(); + + // Setup mock to directly invoke the function (bypassing FunctionInvokingChatClient) + mockChatClient.Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .Returns, ChatOptions, CancellationToken>(async (messages, options, ct) => + { + // Directly invoke the function to simulate null CurrentContext scenario + if (options?.Tools?.FirstOrDefault() is AIFunction function) + { + executionOrder.Add("Direct-Function-Invocation"); + await function.InvokeAsync(new AIFunctionArguments(), ct); + } + return new ChatResponse([new ChatMessage(ChatRole.Assistant, "Response after direct invocation")]); + }); + + var innerAgent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions + { + UseProvidedChatClientAsIs = true + }); + + async ValueTask MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + { + executionOrder.Add("Middleware-Pre"); + capturedContext.Add(context); + var result = await next(context, cancellationToken); + executionOrder.Add("Middleware-Post"); + return result; + } + + var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync); + var messages = new List { new(ChatRole.User, "Test message") }; + + // Act + var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] }); + await middleware.RunAsync(messages, null, options, CancellationToken.None); + + // Assert + Assert.Contains("Direct-Function-Invocation", executionOrder); + Assert.Contains("Middleware-Pre", executionOrder); + Assert.Contains("Function-Executed", executionOrder); + Assert.Contains("Middleware-Post", executionOrder); + + // Verify that the context was created with Iteration = -1 (indicating no ambient context) + Assert.Single(capturedContext); + Assert.Equal(0, capturedContext[0].Iteration); + Assert.Equal("TestFunction", capturedContext[0].Function.Name); + Assert.NotNull(capturedContext[0].Arguments); + } + + #endregion + + #region Error Handling Tests + + /// + /// Tests that exceptions thrown by middleware during pre-invocation surface to the caller. + /// + [Fact] + public async Task RunAsync_MiddlewareThrowsPreInvocation_ExceptionSurfacesAsync() + { + // Arrange + var testFunction = AIFunctionFactory.Create(() => "Function result", "TestFunction", "A test function"); + var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary()); + var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall); + + var innerAgent = new ChatClientAgent(mockChatClient.Object); + var messages = new List { new(ChatRole.User, "Test message") }; + var expectedException = new InvalidOperationException("Pre-invocation error"); + + ValueTask MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + { + throw expectedException; + } + + var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync); + + // Act & Assert + var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] }); + var actualException = await Assert.ThrowsAsync( + () => middleware.RunAsync(messages, null, options, CancellationToken.None)); + + Assert.Same(expectedException, actualException); + } + + /// + /// Tests that exceptions thrown by the function are handled by middleware. + /// + [Fact] + public async Task RunAsync_FunctionThrowsException_MiddlewareCanHandleAsync() + { + // Arrange + var functionException = new InvalidOperationException("Function error"); + string ThrowingFunction() => throw functionException; + var testFunction = AIFunctionFactory.Create(ThrowingFunction, "TestFunction", "A test function"); + var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary()); + var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall); + + var innerAgent = new ChatClientAgent(mockChatClient.Object); + var messages = new List { new(ChatRole.User, "Test message") }; + var middlewareHandledException = false; + + async ValueTask MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + { + try + { + return await next(context, cancellationToken); + } + catch (InvalidOperationException) + { + middlewareHandledException = true; + return "Error handled by middleware"; + } + } + + var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync); + + // Act + var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] }); + await middleware.RunAsync(messages, null, options, CancellationToken.None); + + // Assert + Assert.True(middlewareHandledException); + } + + #endregion + + #region Result Modification Tests + + /// + /// Tests that middleware can modify function results. + /// + [Fact] + public async Task RunAsync_MiddlewareModifiesResult_ModifiedResultUsedAsync() + { + // Arrange + var testFunction = AIFunctionFactory.Create(() => "Original result", "TestFunction", "A test function"); + var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary()); + var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall); + + var innerAgent = new ChatClientAgent(mockChatClient.Object); + var messages = new List { new(ChatRole.User, "Test message") }; + const string ModifiedResult = "Modified by middleware"; + + async ValueTask MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + { + await next(context, cancellationToken); + return ModifiedResult; // Return the modified result instead of setting context property + } + + var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync); + + // Act + var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] }); + var response = await middleware.RunAsync(messages, null, options, CancellationToken.None); + + // Assert + Assert.NotNull(response); + // The modified result should be reflected in the response messages + var functionResultContent = response.Messages + .SelectMany(m => m.Contents) + .OfType() + .FirstOrDefault(); + + Assert.NotNull(functionResultContent); + Assert.Equal(ModifiedResult, functionResultContent.Result); + } + + #endregion + + #region Middleware Chaining Tests + + /// + /// Tests execution order with multiple function middleware instances in a chain. + /// + [Fact] + public async Task RunAsync_MultipleFunctionMiddleware_ExecutesInCorrectOrderAsync() + { + // Arrange + var executionOrder = new List(); + var testFunction = AIFunctionFactory.Create(() => + { + executionOrder.Add("Function-Executed"); + return "Function result"; + }, "TestFunction", "A test function"); + + var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary()); + var mockChatClient = new Mock(); + + // Setup sequence: first call returns function call, subsequent calls return final response + var responseWithFunctionCall = new ChatResponse([ + new ChatMessage(ChatRole.Assistant, [functionCall]) + ]); + var finalResponse = new ChatResponse([ + new ChatMessage(ChatRole.Assistant, "Final response") + ]); + + mockChatClient.SetupSequence(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(responseWithFunctionCall) + .ReturnsAsync(finalResponse); + + var innerAgent = new ChatClientAgent(mockChatClient.Object); + var messages = new List { new(ChatRole.User, "Test message") }; + + async ValueTask FirstMiddlewareAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + { + executionOrder.Add("First-Pre"); + var result = await next(context, cancellationToken); + executionOrder.Add("First-Post"); + return result; + } + + async ValueTask SecondMiddlewareAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + { + executionOrder.Add("Second-Pre"); + var result = await next(context, cancellationToken); + executionOrder.Add("Second-Post"); + return result; + } + + // Create nested middleware chain + var firstMiddleware = new FunctionInvocationDelegatingAgent(innerAgent, FirstMiddlewareAsync); + var secondMiddleware = new FunctionInvocationDelegatingAgent(firstMiddleware, SecondMiddlewareAsync); + + // Act + var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] }); + await secondMiddleware.RunAsync(messages, null, options, CancellationToken.None); + + // Assert + var expectedOrder = new[] { "First-Pre", "Second-Pre", "Function-Executed", "Second-Post", "First-Post" }; + Assert.Equal(expectedOrder, executionOrder); + } + + /// + /// Tests that function middleware works correctly when combined with running middleware. + /// + [Fact] + public async Task RunAsync_FunctionMiddlewareWithRunningMiddleware_BothExecuteAsync() + { + // Arrange + var executionOrder = new List(); + var testFunction = AIFunctionFactory.Create(() => + { + executionOrder.Add("Function-Executed"); + return "Function result"; + }, "TestFunction", "A test function"); + + var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary()); + var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall); + + var innerAgent = new ChatClientAgent(mockChatClient.Object); + var messages = new List { new(ChatRole.User, "Test message") }; + + async Task RunningMiddlewareCallbackAsync(IEnumerable messages, AgentThread? thread, AgentRunOptions? options, AIAgent innerAgent, CancellationToken cancellationToken) + { + executionOrder.Add("Running-Pre"); + var result = await innerAgent.RunAsync(messages, thread, options, cancellationToken); + executionOrder.Add("Running-Post"); + return result; + } + + async ValueTask FunctionMiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + { + executionOrder.Add("Function-Pre"); + var result = await next(context, cancellationToken); + executionOrder.Add("Function-Post"); + return result; + } + + // Create middleware chain: Function -> Running -> Inner using AIAgentBuilder + var runningMiddleware = new AIAgentBuilder(innerAgent) + .Use(RunningMiddlewareCallbackAsync, null) + .Build(); + var functionMiddleware = new FunctionInvocationDelegatingAgent(runningMiddleware, FunctionMiddlewareCallbackAsync); + + // Act + var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] }); + await functionMiddleware.RunAsync(messages, null, options, CancellationToken.None); + + // Assert + Assert.Contains("Running-Pre", executionOrder); + Assert.Contains("Running-Post", executionOrder); + Assert.Contains("Function-Pre", executionOrder); + Assert.Contains("Function-Post", executionOrder); + Assert.Contains("Function-Executed", executionOrder); + } + + #endregion + + #region Streaming Tests + + /// + /// Tests that function middleware works correctly with streaming responses. + /// + [Fact] + public async Task RunStreamingAsync_WithFunctionCall_InvokesMiddlewareAsync() + { + // Arrange + var executionOrder = new List(); + var testFunction = AIFunctionFactory.Create(() => + { + executionOrder.Add("Function-Executed"); + return "Function result"; + }, "TestFunction", "A test function"); + + var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary()); + var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall); + + // Setup streaming response with function calls + var streamingResponse = new ChatResponseUpdate[] + { + new() { Contents = [functionCall] }, // Include function call in streaming response + new() { Contents = [new TextContent("Streaming response")] } + }; + + mockChatClient.Setup(c => c.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns(streamingResponse.ToAsyncEnumerable()); + + var innerAgent = new ChatClientAgent(mockChatClient.Object); + var messages = new List { new(ChatRole.User, "Test message") }; + + async ValueTask MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + { + executionOrder.Add("Middleware-Pre"); + var result = await next(context, cancellationToken); + executionOrder.Add("Middleware-Post"); + return result; + } + + var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync); + + // Act + var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] }); + var responseUpdates = new List(); + await foreach (var update in middleware.RunStreamingAsync(messages, null, options, CancellationToken.None)) + { + responseUpdates.Add(update); + } + + // Assert + Assert.NotEmpty(responseUpdates); + Assert.Contains("Middleware-Pre", executionOrder); + Assert.Contains("Function-Executed", executionOrder); + Assert.Contains("Middleware-Post", executionOrder); + } + + #endregion + + #region Edge Cases + + /// + /// Tests that middleware is not invoked when no function calls are made. + /// + [Fact] + public async Task RunAsync_NoFunctionCalls_MiddlewareNotInvokedAsync() + { + // Arrange + var middlewareInvoked = false; + var mockChatClient = CreateMockChatClient( + new ChatResponse([new ChatMessage(ChatRole.Assistant, "Regular response")])); + + var innerAgent = new ChatClientAgent(mockChatClient.Object); + var messages = new List { new(ChatRole.User, "Test message") }; + + async ValueTask MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + { + middlewareInvoked = true; + return await next(context, cancellationToken); + } + + var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync); + + // Act + await middleware.RunAsync(messages, null, null, CancellationToken.None); + + // Assert + Assert.False(middlewareInvoked); + } + + /// + /// Tests that middleware handles cancellation tokens correctly. + /// + [Fact] + public async Task RunAsync_CancellationToken_PropagatedToMiddlewareAsync() + { + // Arrange + var testFunction = AIFunctionFactory.Create(() => "Function result", "TestFunction", "A test function"); + var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary()); + var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall); + + var innerAgent = new ChatClientAgent(mockChatClient.Object); + var messages = new List { new(ChatRole.User, "Test message") }; + var cancellationTokenSource = new CancellationTokenSource(); + var expectedToken = cancellationTokenSource.Token; + CancellationToken? capturedToken = null; + + async ValueTask MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + { + capturedToken = cancellationToken; + return await next(context, cancellationToken); + } + + var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync); + + // Act + var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] }); + await middleware.RunAsync(messages, null, options, expectedToken); + + // Assert + Assert.Equal(expectedToken, capturedToken); + } + + /// + /// Tests that middleware can prevent function execution by not calling next(). + /// + [Fact] + public async Task RunAsync_MiddlewareDoesNotCallNext_FunctionNotExecutedAsync() + { + // Arrange + var functionExecuted = false; + var testFunction = AIFunctionFactory.Create(() => + { + functionExecuted = true; + return "Function result"; + }, "TestFunction", "A test function"); + + var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary()); + var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall); + + var innerAgent = new ChatClientAgent(mockChatClient.Object); + var messages = new List { new(ChatRole.User, "Test message") }; + + ValueTask MiddlewareCallbackAsync(AIAgent agent, FunctionInvocationContext context, Func> next, CancellationToken cancellationToken) + { + // Don't call next() - this should prevent function execution + // Return the blocked result directly + return new ValueTask("Blocked by middleware"); + } + + var middleware = new FunctionInvocationDelegatingAgent(innerAgent, MiddlewareCallbackAsync); + + // Act + var options = new ChatClientAgentRunOptions(new ChatOptions { Tools = [testFunction] }); + var response = await middleware.RunAsync(messages, null, options, CancellationToken.None); + + // Assert + Assert.False(functionExecuted); + Assert.NotNull(response); + + // Verify the middleware result is used + var functionResultContent = response.Messages + .SelectMany(m => m.Contents) + .OfType() + .FirstOrDefault(); + + Assert.NotNull(functionResultContent); + Assert.Equal("Blocked by middleware", functionResultContent.Result); + } + + #endregion + + /// + /// Creates a mock IChatClient with predefined responses for testing. + /// + /// The responses to return in sequence. + /// A configured mock IChatClient. + private static Mock CreateMockChatClient(params ChatResponse[] responses) + { + var mockChatClient = new Mock(); + var responseQueue = new Queue(responses); + + mockChatClient.Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(() => responseQueue.Count > 0 ? responseQueue.Dequeue() : responses.LastOrDefault() ?? CreateDefaultResponse()); + + return mockChatClient; + } + + /// + /// Creates a mock IChatClient that returns responses with function calls for testing function middleware. + /// + /// The function calls to include in responses. + /// A configured mock IChatClient. + private static Mock CreateMockChatClientWithFunctionCalls(params FunctionCallContent[] functionCalls) + { + var mockChatClient = new Mock(); + + var responseWithFunctionCalls = new ChatResponse([ + new ChatMessage(ChatRole.Assistant, functionCalls.Cast().ToList()) + ]); + + mockChatClient.Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(responseWithFunctionCalls); + + return mockChatClient; + } + + /// + /// Creates a default ChatResponse for fallback scenarios. + /// + /// A default ChatResponse. + private static ChatResponse CreateDefaultResponse() + { + return new ChatResponse([new ChatMessage(ChatRole.Assistant, "Default response")]); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj index 8edbb22dc1..ef5f827d76 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj @@ -10,6 +10,7 @@ + diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAIAgentBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAIAgentBuilderExtensionsTests.cs new file mode 100644 index 0000000000..9181d81f63 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAIAgentBuilderExtensionsTests.cs @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Unit tests for the class. +/// +public class OpenTelemetryAIAgentBuilderExtensionsTests +{ + /// + /// Verify that UseOpenTelemetry throws ArgumentNullException when builder is null. + /// + [Fact] + public void UseOpenTelemetry_WithNullBuilder_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws("builder", () => + OpenTelemetryAIAgentBuilderExtensions.UseOpenTelemetry(null!)); + } + + /// + /// Verify that UseOpenTelemetry returns an OpenTelemetryAgent. + /// + [Fact] + public void UseOpenTelemetry_WithValidBuilder_ReturnsOpenTelemetryAgent() + { + // Arrange + var mockAgent = new Mock(); + var builder = new AIAgentBuilder(mockAgent.Object); + + // Act + var result = builder.UseOpenTelemetry().Build(); + + // Assert + Assert.IsType(result); + } + + /// + /// Verify that UseOpenTelemetry with logger factory works correctly. + /// + [Fact] + public void UseOpenTelemetry_WithLoggerFactory_UsesProvidedLoggerFactory() + { + // Arrange + var mockAgent = new Mock(); + using var loggerFactory = LoggerFactory.Create(builder => { }); + var builder = new AIAgentBuilder(mockAgent.Object); + + // Act + var result = builder.UseOpenTelemetry(loggerFactory).Build(); + + // Assert + Assert.IsType(result); + } + + /// + /// Verify that UseOpenTelemetry with source name works correctly. + /// + [Fact] + public void UseOpenTelemetry_WithSourceName_WorksCorrectly() + { + // Arrange + var mockAgent = new Mock(); + var builder = new AIAgentBuilder(mockAgent.Object); + const string SourceName = "TestSource"; + + // Act + var result = builder.UseOpenTelemetry(sourceName: SourceName).Build(); + + // Assert + Assert.IsType(result); + } + + /// + /// Verify that UseOpenTelemetry with configure action works correctly. + /// + [Fact] + public void UseOpenTelemetry_WithConfigureAction_CallsConfigureAction() + { + // Arrange + var mockAgent = new Mock(); + var builder = new AIAgentBuilder(mockAgent.Object); + var configureWasCalled = false; + + // Act + var result = builder.UseOpenTelemetry(configure: agent => + { + configureWasCalled = true; + Assert.NotNull(agent); + Assert.IsType(agent); + }).Build(); + + // Assert + Assert.True(configureWasCalled); + Assert.IsType(result); + } + + /// + /// Verify that UseOpenTelemetry returns the same builder instance for chaining. + /// + [Fact] + public void UseOpenTelemetry_ReturnsBuilderForChaining() + { + // Arrange + var mockAgent = new Mock(); + var builder = new AIAgentBuilder(mockAgent.Object); + + // Act + var result = builder.UseOpenTelemetry(); + + // Assert + Assert.Same(builder, result); + } + + /// + /// Verify that UseOpenTelemetry with all parameters works correctly. + /// + [Fact] + public void UseOpenTelemetry_WithAllParameters_WorksCorrectly() + { + // Arrange + var mockAgent = new Mock(); + using var loggerFactory = LoggerFactory.Create(builder => { }); + var builder = new AIAgentBuilder(mockAgent.Object); + const string SourceName = "TestSource"; + var configureWasCalled = false; + + // Act + var result = builder.UseOpenTelemetry( + loggerFactory: loggerFactory, + sourceName: SourceName, + configure: agent => + { + configureWasCalled = true; + Assert.NotNull(agent); + }).Build(); + + // Assert + Assert.True(configureWasCalled); + Assert.IsType(result); + } +}