diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs index c9e0dd23887..31bfde308ba 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs @@ -94,6 +94,12 @@ internal static IChatClient WithDefaultAgentMiddleware(this IChatClient chatClie chatBuilder.Use(innerClient => new PerServiceCallChatHistoryPersistingChatClient(innerClient)); } + // DeferredOpenTelemetryChatClient is registered last so it sits as the innermost decorator, directly + // above the leaf client and below FunctionInvokingChatClient. It is inert until an OpenTelemetryAgent + // activates it. Placing OpenTelemetry below FICC ensures the chat span closes before tools are invoked, + // so FICC emits execute_tool spans on the agent source. + chatBuilder.Use(innerClient => new DeferredOpenTelemetryChatClient(innerClient)); + var agentChatClient = chatBuilder.Build(services); if (options?.ChatOptions?.Tools is { Count: > 0 }) diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/DeferredOpenTelemetryChatClient.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/DeferredOpenTelemetryChatClient.cs new file mode 100644 index 00000000000..98dc1acf2b7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/DeferredOpenTelemetryChatClient.cs @@ -0,0 +1,133 @@ +// 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.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A delegating chat client that reserves a position for OpenTelemetry instrumentation directly above +/// the leaf and below the in a +/// pipeline. +/// +/// +/// +/// The slot is inert until is called: it simply forwards to its inner client. +/// When the agent is wrapped by an , that agent activates the slot with +/// the resolved source name, at which point the slot routes calls through an +/// so chat spans are emitted below the +/// . +/// +/// +/// Positioning OpenTelemetry below FICC is required for tool telemetry: the chat span then closes before +/// FICC invokes tools, so is the invoke_agent span and +/// FICC emits execute_tool spans on the agent source. +/// +/// +internal sealed class DeferredOpenTelemetryChatClient : DelegatingChatClient +{ + private readonly object _activationLock = new(); + private volatile IChatClient _target; + private OpenTelemetryChatClient? _activatedClient; + + /// + /// Initializes a new instance of the class in its inert state. + /// + /// The underlying chat client to forward to until the slot is activated. + public DeferredOpenTelemetryChatClient(IChatClient innerClient) + : base(innerClient) + { + this._target = innerClient; + } + + /// Gets a value indicating whether the slot has been activated. + public bool IsActive => !ReferenceEquals(this._target, this.InnerClient); + + /// + /// Gets or sets a value indicating whether the activated should + /// include potentially sensitive information (such as message content) in telemetry. Reading or writing + /// this property is a no-op while the slot is inert; the owning applies + /// and propagates the value once the slot is activated. + /// + public bool EnableSensitiveData + { + get => this._activatedClient?.EnableSensitiveData ?? false; + set + { + if (this._activatedClient is { } activatedClient) + { + activatedClient.EnableSensitiveData = value; + } + } + } + + /// + /// Activates the slot so that calls are routed through an wrapping + /// the inner client under the specified . Idempotent and thread-safe; a + /// second call (or a call after another thread activated the slot) is a no-op. + /// + /// The telemetry source name to emit chat spans under. + public void Activate(string sourceName) + { + if (this.IsActive) + { + return; + } + + lock (this._activationLock) + { + if (this.IsActive) + { + return; + } + + var activatedTarget = this.InnerClient.AsBuilder().UseOpenTelemetry(sourceName: sourceName).Build(); + + // Capture the OpenTelemetryChatClient so the owning agent can propagate EnableSensitiveData to it + // (the agent's value may be set after construction, e.g. via the UseOpenTelemetry configure callback). + this._activatedClient = activatedTarget.GetService(typeof(OpenTelemetryChatClient)) as OpenTelemetryChatClient; + this._target = activatedTarget; + } + } + + /// + public override Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => + this._target.GetResponseAsync(messages, options, cancellationToken); + + /// + public override IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => + this._target.GetStreamingResponseAsync(messages, options, cancellationToken); + + /// + public override object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + // Return this slot for its own type and base contracts; otherwise forward to the current target so + // that, once activated, queries such as OpenTelemetryChatClient and ActivitySource resolve to the + // activated instrumentation rather than the bare leaf. + return serviceKey is null && serviceType.IsInstanceOfType(this) + ? this + : this._target.GetService(serviceType, serviceKey); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing && !ReferenceEquals(this._target, this.InnerClient)) + { + // When activated, _target is an OpenTelemetryChatClient wrapping the inner client; dispose it so its + // own telemetry resources are released. It also disposes the inner client, which is idempotent with the + // base.Dispose call below. + this._target.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs index 85ae97e1ded..136b7251e49 100644 --- a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs @@ -42,6 +42,12 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable /// private readonly bool _autoWireChatClient; + /// + /// The auto-wired below-FICC telemetry slot, when one was activated. Cached so that updates to + /// made after construction can be propagated to it. + /// + private DeferredOpenTelemetryChatClient? _innerTelemetrySlot; + /// Initializes a new instance of the class. /// The underlying to be augmented with telemetry capabilities. /// @@ -91,6 +97,8 @@ public OpenTelemetryAgent(AIAgent innerAgent, string? sourceName, bool autoWireC this._otelClient = new OpenTelemetryChatClient( new ForwardingChatClient(this), sourceName: this._sourceName); + + this.TryActivateInnerChatClientTelemetry(); } /// @@ -120,7 +128,16 @@ public OpenTelemetryAgent(AIAgent innerAgent, string? sourceName, bool autoWireC public bool EnableSensitiveData { get => this._otelClient.EnableSensitiveData; - set => this._otelClient.EnableSensitiveData = value; + set + { + this._otelClient.EnableSensitiveData = value; + + // Keep the auto-wired below-FICC slot in sync so its chat span captures message content too. + if (this._innerTelemetrySlot is { } slot) + { + slot.EnableSensitiveData = value; + } + } } /// @@ -204,84 +221,52 @@ public ForwardedOptions(AgentRunOptions? options, AgentSession? session, Activit } /// - /// If auto-wiring is enabled and the inner agent is a whose underlying - /// is not already instrumented with , returns a - /// new with a - /// that wraps the chat client with . When is a - /// plain (the base type, not ), the base - /// properties are copied onto the new so high-level callers that pass - /// the abstract still benefit from auto-wiring and propagate their settings to - /// the inner agent. Otherwise, returns unchanged. + /// When auto-wiring is enabled and the inner agent is a whose underlying + /// is not already instrumented, activates the in-place + /// slot so that chat spans are emitted below the + /// under this agent's source name. Positioning OpenTelemetry below FICC + /// is what allows FICC to emit execute_tool spans on the agent source. Respects + /// and is a no-op when no slot is reachable. /// - private AgentRunOptions? GetRunOptionsWithChatClientWiring(AgentRunOptions? options) + private void TryActivateInnerChatClientTelemetry() { if (!this._autoWireChatClient) { - return options; + return; } - // The auto-wiring only applies when a ChatClientAgent is reachable from the inner agent. Otherwise, no-op. + // Auto-wiring only applies when a ChatClientAgent is reachable from the inner agent. Otherwise, no-op. // Use GetService rather than a type check so wrapping agents that expose a nested ChatClientAgent are supported. var chatClientAgent = this.InnerAgent.GetService(); if (chatClientAgent is null) { - return options; + return; } // Respect ChatClientAgentOptions.UseProvidedChatClientAsIs: don't decorate the chat client when the user opted out. if (chatClientAgent.GetService()?.UseProvidedChatClientAsIs is true) { - return options; + return; } - // Capture the underlying IChatClient and check whether it is already instrumented. + // Don't activate when the chat client is already instrumented (e.g. the caller added their own + // OpenTelemetryChatClient), to avoid emitting duplicate chat spans. var chatClient = chatClientAgent.GetService(); if (chatClient is null || chatClient.GetService(typeof(OpenTelemetryChatClient)) is not null) { - return options; - } - - string sourceName = this._sourceName; - bool enableSensitiveData = this.EnableSensitiveData; - static IChatClient WrapIfNeeded(IChatClient cc, string sourceName, bool enableSensitiveData) => - cc.GetService(typeof(OpenTelemetryChatClient)) is not null - ? cc - : cc.AsBuilder().UseOpenTelemetry(sourceName: sourceName, configure: o => o.EnableSensitiveData = enableSensitiveData).Build(); - - if (options is ChatClientAgentRunOptions ccOptions) - { - // Don't mutate the caller's options; clone and chain any caller-provided factory. - // If the user factory already returns an OpenTelemetry-instrumented client, don't double-wrap. - var clone = (ChatClientAgentRunOptions)ccOptions.Clone(); - var userFactory = clone.ChatClientFactory; - clone.ChatClientFactory = cc => WrapIfNeeded(userFactory is null ? cc : userFactory(cc), sourceName, enableSensitiveData); - return clone; + return; } - // For a plain AgentRunOptions (or null), create a ChatClientAgentRunOptions and preserve - // any base AgentRunOptions properties from the caller so they reach the inner agent. - var newOptions = new ChatClientAgentRunOptions - { - ChatClientFactory = cc => WrapIfNeeded(cc, sourceName, enableSensitiveData), - }; - - if (options is not null) + // Activate the pre-placed slot in-place (below FICC) rather than wrapping a new OpenTelemetryChatClient + // around the whole pipeline on each run. Cache it and seed its EnableSensitiveData from the current + // value so a later change to this agent's EnableSensitiveData can be propagated to the inner chat span. + if (chatClient.GetService(typeof(DeferredOpenTelemetryChatClient)) is DeferredOpenTelemetryChatClient slot) { - CopyBaseAgentRunOptions(options, newOptions); + slot.Activate(this._sourceName); + slot.EnableSensitiveData = this.EnableSensitiveData; + this._innerTelemetrySlot = slot; } - - return newOptions; - } - -#pragma warning disable MEAI001 // ContinuationToken is experimental; copy it through to preserve caller-provided value. - private static void CopyBaseAgentRunOptions(AgentRunOptions source, AgentRunOptions target) - { - target.ContinuationToken = source.ContinuationToken; - target.AllowBackgroundResponses = source.AllowBackgroundResponses; - target.AdditionalProperties = source.AdditionalProperties?.Clone(); - target.ResponseFormat = source.ResponseFormat; } -#pragma warning restore MEAI001 /// The stub used to delegate from the into the inner . /// @@ -295,11 +280,9 @@ public async Task GetResponseAsync( // Update the current activity to reflect the agent invocation. parentAgent.UpdateCurrentActivity(fo?.CurrentActivity); - // If enabled, wire the underlying chat client with OpenTelemetryChatClient via ChatClientFactory. - var runOptions = parentAgent.GetRunOptionsWithChatClientWiring(fo?.Options); - - // Invoke the inner agent. - var response = await parentAgent.InnerAgent.RunAsync(messages, fo?.Session, runOptions, cancellationToken).ConfigureAwait(false); + // Invoke the inner agent. Chat-level telemetry is emitted by the in-place DeferredOpenTelemetryChatClient + // slot (below FICC), activated once at construction; no per-run chat-client wiring is needed here. + var response = await parentAgent.InnerAgent.RunAsync(messages, fo?.Session, fo?.Options, cancellationToken).ConfigureAwait(false); // Wrap the response in a ChatResponse so we can pass it back through OpenTelemetryChatClient. return response.AsChatResponse(); @@ -313,11 +296,9 @@ public async IAsyncEnumerable GetStreamingResponseAsync( // Update the current activity to reflect the agent invocation. parentAgent.UpdateCurrentActivity(fo?.CurrentActivity); - // If enabled, wire the underlying chat client with OpenTelemetryChatClient via ChatClientFactory. - var runOptions = parentAgent.GetRunOptionsWithChatClientWiring(fo?.Options); - - // Invoke the inner agent. - await foreach (var update in parentAgent.InnerAgent.RunStreamingAsync(messages, fo?.Session, runOptions, cancellationToken).ConfigureAwait(false)) + // Invoke the inner agent. Chat-level telemetry is emitted by the in-place DeferredOpenTelemetryChatClient + // slot (below FICC), activated once at construction; no per-run chat-client wiring is needed here. + await foreach (var update in parentAgent.InnerAgent.RunStreamingAsync(messages, fo?.Session, fo?.Options, cancellationToken).ConfigureAwait(false)) { // Wrap the response updates in ChatResponseUpdates so we can pass them back through OpenTelemetryChatClient. yield return update.AsChatResponseUpdate(); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs index 9717c6157bf..12a8d663f4f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs @@ -795,9 +795,9 @@ public async Task AutoWireChatClient_PreservesUserChatClientFactory_Async() [Fact] public async Task AutoWireChatClient_PlainAgentRunOptions_PreservesBaseProperties_Async() { - // Auto-wiring converts a plain AgentRunOptions into a ChatClientAgentRunOptions. The base - // properties (ContinuationToken, AllowBackgroundResponses, AdditionalProperties, ResponseFormat) - // must be preserved so they reach the inner agent. + // The auto-wire no longer rewrites the caller's options (the slot below FICC is activated once at + // construction), so a plain AgentRunOptions reaches the inner agent unchanged with all base + // properties (AllowBackgroundResponses, AdditionalProperties, ResponseFormat) intact. AgentRunOptions? observedOptions = null; var fakeChatClient = new AutoWireTestChatClient(); var innerChatClientAgent = new ChatClientAgent(fakeChatClient); @@ -827,17 +827,27 @@ public async Task AutoWireChatClient_PlainAgentRunOptions_PreservesBasePropertie _ = await agent.RunAsync("hi", options: inputOptions); + // Options flow through unchanged (same instance, no conversion to ChatClientAgentRunOptions). Assert.NotNull(observedOptions); - Assert.IsType(observedOptions); - Assert.Equal(true, observedOptions!.AllowBackgroundResponses); + Assert.Same(inputOptions, observedOptions); + Assert.Equal(true, observedOptions.AllowBackgroundResponses); Assert.Same(ChatResponseFormat.Json, observedOptions.ResponseFormat); Assert.NotNull(observedOptions.AdditionalProperties); Assert.Equal("customValue", observedOptions.AdditionalProperties!["customKey"]); } [Fact] - public async Task AutoWireChatClient_UserFactoryReturnsInstrumentedClient_DoesNotDoubleWrap_Async() + public async Task AutoWireChatClient_UserFactoryAddsOwnOTel_CoexistsWithBelowFiccSlot_Async() { + // This is NOT a single model call counted twice. One model call is observed by two independent + // OpenTelemetry layers, so each layer emits its own "chat" span: + // - the framework's slot, always activated below FICC by OpenTelemetryAgent. This below-FICC layer + // is what lets FICC emit execute_tool spans, so it must remain even when the caller adds their own + // instrumentation. Dropping it to avoid the second span would reintroduce the missing-tool-span bug. + // - the caller's per-run ChatClientFactory, which wraps the pipeline above FICC with its own + // OpenTelemetryChatClient. + // The two chat spans nest and measure different scopes (the above-FICC span covers the whole tool loop, + // the below-FICC span covers each individual model call), so both coexisting is the intended result. var sourceName = Guid.NewGuid().ToString(); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() @@ -849,7 +859,7 @@ public async Task AutoWireChatClient_UserFactoryReturnsInstrumentedClient_DoesNo var inner = new ChatClientAgent(fakeChatClient); using var agent = new OpenTelemetryAgent(inner, sourceName); - // User factory wraps the chat client with OpenTelemetryChatClient itself. + // User factory wraps the chat client with OpenTelemetryChatClient itself (above FICC). var runOptions = new ChatClientAgentRunOptions { ChatClientFactory = cc => cc.AsBuilder().UseOpenTelemetry(sourceName: sourceName).Build(), @@ -857,8 +867,10 @@ public async Task AutoWireChatClient_UserFactoryReturnsInstrumentedClient_DoesNo _ = await agent.RunAsync("hi", options: runOptions); - // Expect 2 activities (invoke_agent + a single chat span). If we double-wrapped, we would see 3. - Assert.Equal(2, activities.Count); + // invoke_agent + two chat spans: one from the caller's above-FICC OTel and one from the slot below FICC. + Assert.Equal(3, activities.Count); + Assert.Contains(activities, a => a.DisplayName.StartsWith("invoke_agent", StringComparison.Ordinal)); + Assert.Equal(2, activities.Count(a => string.Equals(a.GetTagItem("gen_ai.operation.name") as string, "chat", StringComparison.Ordinal))); } [Theory] @@ -893,8 +905,8 @@ public async Task Ctor_NullOrWhitespaceSourceName_AutoWiredChatClientUsesDefault [Fact] public async Task AutoWireChatClient_PlainAgentRunOptions_PreservesContinuationToken_Async() { - // ContinuationToken is the fourth base AgentRunOptions property copied by CopyBaseAgentRunOptions - // and is not exercised by AutoWireChatClient_PlainAgentRunOptions_PreservesBaseProperties_Async. + // ContinuationToken on a plain AgentRunOptions must reach the inner agent unchanged now that the + // auto-wire passes the caller's options straight through (no conversion to ChatClientAgentRunOptions). AgentRunOptions? observedOptions = null; var fakeChatClient = new AutoWireTestChatClient(); var innerChatClientAgent = new ChatClientAgent(fakeChatClient); @@ -921,8 +933,8 @@ public async Task AutoWireChatClient_PlainAgentRunOptions_PreservesContinuationT _ = await agent.RunAsync("hi", options: inputOptions); Assert.NotNull(observedOptions); - Assert.IsType(observedOptions); - Assert.Same(token, observedOptions!.ContinuationToken); + Assert.Same(inputOptions, observedOptions); + Assert.Same(token, observedOptions.ContinuationToken); } #pragma warning restore MEAI001 @@ -1061,11 +1073,10 @@ public async Task AutoWireChatClient_PlainAgentRunOptions_RealChatClientAgent_St [InlineData(true, true)] public async Task AutoWireChatClient_EnableSensitiveData_PropagatedToInnerChatClient_Async(bool enableSensitiveData, bool streaming) { - // Regression test for: when EnableSensitiveData is set on OpenTelemetryAgent, the auto-wired - // inner OpenTelemetryChatClient must also have EnableSensitiveData propagated to it. Previously, - // GetRunOptionsWithChatClientWiring created the inner client without passing EnableSensitiveData, - // so the inner chat span would never emit gen_ai.input.messages / gen_ai.output.messages even - // when the caller explicitly set EnableSensitiveData = true. + // Regression test (issue #5873): when EnableSensitiveData is set on OpenTelemetryAgent, the auto-wired + // inner OpenTelemetryChatClient (the below-FICC slot) must also have EnableSensitiveData propagated to it, + // so the inner chat span captures gen_ai.input.messages / gen_ai.output.messages. The agent sets the value + // on the slot after construction, since EnableSensitiveData is typically set via the UseOpenTelemetry callback. var sourceName = Guid.NewGuid().ToString(); var activities = new List(); using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() @@ -1109,6 +1120,71 @@ public async Task AutoWireChatClient_EnableSensitiveData_PropagatedToInnerChatCl } } + [Fact] + public async Task AutoWireChatClient_EmitsExecuteToolSpans_Async() + { + // The core of the OTel-below-FICC fix: with the slot active below FICC, the inner chat span closes + // before FICC invokes tools, so Activity.Current is the invoke_agent span and FICC emits an + // execute_tool span on the agent source, parented under invoke_agent. + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + var tool = AIFunctionFactory.Create(() => "sunny", "get_weather"); + var fakeChatClient = new ToolCallingTestChatClient(); + var inner = new ChatClientAgent(fakeChatClient, new ChatClientAgentOptions + { + ChatOptions = new ChatOptions { Tools = [tool] }, + }); + using var agent = new OpenTelemetryAgent(inner, sourceName); + + _ = await agent.RunAsync("weather?"); + + var invokeAgent = Assert.Single(activities, a => a.DisplayName.StartsWith("invoke_agent", StringComparison.Ordinal)); + var executeTool = Assert.Single(activities, a => a.DisplayName.StartsWith("execute_tool", StringComparison.Ordinal)); + Assert.Equal(sourceName, executeTool.Source.Name); + Assert.Equal(invokeAgent.SpanId, executeTool.ParentSpanId); + Assert.Contains(activities, a => string.Equals(a.GetTagItem("gen_ai.operation.name") as string, "chat", StringComparison.Ordinal)); + } + + [Fact] + public async Task DeferredOpenTelemetryChatClient_InertUntilActivated_Async() + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + var leaf = new AutoWireTestChatClient(); + using var slot = new DeferredOpenTelemetryChatClient(leaf); + + // Inert: resolves itself for its own type and forwards other lookups to the inner client. The bare + // leaf is not instrumented, so the OpenTelemetryChatClient lookup is null and no span is emitted. + Assert.False(slot.IsActive); + Assert.Same(slot, slot.GetService(typeof(DeferredOpenTelemetryChatClient))); + Assert.Null(slot.GetService(typeof(OpenTelemetryChatClient))); + _ = await slot.GetResponseAsync("hi"); + Assert.Empty(activities); + + // Active: routes through an OpenTelemetryChatClient that emits a chat span on the source. + slot.Activate(sourceName); + Assert.True(slot.IsActive); + Assert.NotNull(slot.GetService(typeof(OpenTelemetryChatClient))); + _ = await slot.GetResponseAsync("hi"); + var chat = Assert.Single(activities); + Assert.Equal("chat", chat.GetTagItem("gen_ai.operation.name") as string); + + // Idempotent: a second activation does not replace the existing wrapper. + var target = slot.GetService(typeof(OpenTelemetryChatClient)); + slot.Activate(sourceName); + Assert.Same(target, slot.GetService(typeof(OpenTelemetryChatClient))); + } + private sealed class AutoWireTestChatClient : IChatClient { public Action, ChatOptions?>? OnGetResponseAsync { get; set; } @@ -1132,5 +1208,40 @@ public async IAsyncEnumerable GetStreamingResponseAsync(IEnu public void Dispose() { } } + private sealed class ToolCallingTestChatClient : IChatClient + { + private int _callCount; + + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + // First call returns a tool call so FICC invokes the tool; the second call returns the final text. + if (Interlocked.Increment(ref this._callCount) == 1) + { + var call = new FunctionCallContent("call_1", "get_weather", new Dictionary()); + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, [call]))); + } + + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done"))); + } + + public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (Interlocked.Increment(ref this._callCount) == 1) + { + yield return new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("call_1", "get_weather", new Dictionary())]); + await Task.Yield(); + yield break; + } + + await Task.Yield(); + yield return new ChatResponseUpdate(ChatRole.Assistant, "done"); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => + serviceType?.IsInstanceOfType(this) == true ? this : null; + + public void Dispose() { } + } + #endregion }