From 35f44e854e11415e47a19774446b731ad9bcc43b Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 25 Mar 2026 18:22:13 -0400 Subject: [PATCH 01/22] .NET: fix: HandoffAgentExecutor does not output any response when non-streaming (#4745) * fix: HandoffAgentExecutor does not output any reponse when non-streaming * fix: Ensure Workflow outputs persisted in chat history when hosted AsAgent * fix: Remove duplicate history entry creation and ad test * test: Add streaming tests for AsAgent to smoke tests * feat: Add output configurability to Handoffs --- .../HandoffsWorkflowBuilder.cs | 46 +++- .../Specialized/AIAgentHostExecutor.cs | 17 +- .../Specialized/HandoffAgentExecutor.cs | 19 +- .../WorkflowChatHistoryProvider.cs | 8 +- .../WorkflowHostAgent.cs | 17 +- .../WorkflowSession.cs | 199 ++++++++---------- .../AIAgentHostExecutorTests.cs | 64 +----- .../AIAgentHostingExecutorTestsBase.cs | 79 +++++++ .../HandoffAgentExecutorTests.cs | 71 +++++++ .../WorkflowHostSmokeTests.cs | 68 +++++- 10 files changed, 404 insertions(+), 184 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostingExecutorTestsBase.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs index bd0b3114f1..ccb993b188 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs @@ -13,10 +13,18 @@ namespace Microsoft.Agents.AI.Workflows; /// public sealed class HandoffsWorkflowBuilder { - internal const string FunctionPrefix = "handoff_to_"; + /// + /// The prefix for function calls that trigger handoffs to other agents; the full name is then `{FunctionPrefix}<agent_id>`, + /// where `<agent_id>` is the ID of the target agent to hand off to. + /// + public const string FunctionPrefix = "handoff_to_"; + private readonly AIAgent _initialAgent; private readonly Dictionary> _targets = []; private readonly HashSet _allAgents = new(AIAgentIDEqualityComparer.Instance); + + private bool _emitAgentResponseEvents; + private bool _emitAgentResponseUpdateEvents; private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly; /// @@ -47,9 +55,13 @@ public sealed class HandoffsWorkflowBuilder """; /// - /// Sets additional instructions to provide to an agent that has handoffs about how and when to - /// perform them. + /// Sets instructions to provide to each agent that has handoffs about how and when to perform them. /// + /// + /// In the vast majority of cases, the will be sufficient, and there will be no need to customize. + /// If you do provide alternate instructions, remember to explain the mechanics of the handoff function tool call, using see + /// constant. + /// /// The instructions to provide, or to restore the default instructions. public HandoffsWorkflowBuilder WithHandoffInstructions(string? instructions) { @@ -57,6 +69,29 @@ public sealed class HandoffsWorkflowBuilder return this; } + /// + /// Sets a value indicating whether agent streaming update events should be emitted during execution. + /// If , the value will be taken from the + /// + /// + /// + public HandoffsWorkflowBuilder EmitAgentResponseUpdateEvents(bool emitAgentResponseUpdateEvents = true) + { + this._emitAgentResponseUpdateEvents = emitAgentResponseUpdateEvents; + return this; + } + + /// + /// Sets a value indicating whether aggregated agent response events should be emitted during execution. + /// + /// + /// + public HandoffsWorkflowBuilder EmitAgentResponseEvents(bool emitAgentResponseEvents = true) + { + this._emitAgentResponseEvents = emitAgentResponseEvents; + return this; + } + /// /// Sets the behavior for filtering and contents from /// s flowing through the handoff workflow. Defaults to . @@ -175,7 +210,10 @@ public sealed class HandoffsWorkflowBuilder HandoffsEndExecutor end = new(); WorkflowBuilder builder = new(start); - HandoffAgentExecutorOptions options = new(this.HandoffInstructions, this._toolCallFilteringBehavior); + HandoffAgentExecutorOptions options = new(this.HandoffInstructions, + this._emitAgentResponseEvents, + this._emitAgentResponseUpdateEvents, + this._toolCallFilteringBehavior); // Create an AgentExecutor for each again. Dictionary executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, options)); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs index cf9ddbe3a3..3f3d83fbee 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs @@ -12,6 +12,15 @@ namespace Microsoft.Agents.AI.Workflows.Specialized; internal record AIAgentHostState(JsonElement? ThreadState, bool? CurrentTurnEmitEvents); +internal static class TurnExtensions +{ + public static bool ShouldEmitStreamingEvents(this TurnToken token, bool? agentSetting) + => token.EmitEvents ?? agentSetting ?? false; + + public static bool ShouldEmitStreamingEvents(bool? turnTokenSetting, bool? agentSetting) + => turnTokenSetting ?? agentSetting ?? false; +} + internal sealed class AIAgentHostExecutor : ChatProtocolExecutor { private readonly AIAgent _agent; @@ -104,9 +113,6 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor }, context, cancellationToken); } - public bool ShouldEmitStreamingEvents(bool? emitEvents) - => emitEvents ?? this._options.EmitAgentUpdateEvents ?? false; - private async ValueTask EnsureSessionAsync(IWorkflowContext context, CancellationToken cancellationToken) => this._session ??= await this._agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); @@ -175,7 +181,10 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor } protected override ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default) - => this.ContinueTurnAsync(messages, context, this.ShouldEmitStreamingEvents(emitEvents), cancellationToken); + => this.ContinueTurnAsync(messages, + context, + TurnExtensions.ShouldEmitStreamingEvents(turnTokenSetting: emitEvents, this._options.EmitAgentUpdateEvents), + cancellationToken); private async ValueTask InvokeAgentAsync(IEnumerable messages, IWorkflowContext context, bool emitEvents, CancellationToken cancellationToken = default) { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs index d1367b83ad..21519e07ee 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs @@ -14,14 +14,20 @@ namespace Microsoft.Agents.AI.Workflows.Specialized; internal sealed class HandoffAgentExecutorOptions { - public HandoffAgentExecutorOptions(string? handoffInstructions, HandoffToolCallFilteringBehavior toolCallFilteringBehavior) + public HandoffAgentExecutorOptions(string? handoffInstructions, bool emitAgentResponseEvents, bool? emitAgentResponseUpdateEvents, HandoffToolCallFilteringBehavior toolCallFilteringBehavior) { this.HandoffInstructions = handoffInstructions; + this.EmitAgentResponseEvents = emitAgentResponseEvents; + this.EmitAgentResponseUpdateEvents = emitAgentResponseUpdateEvents; this.ToolCallFilteringBehavior = toolCallFilteringBehavior; } public string? HandoffInstructions { get; set; } + public bool EmitAgentResponseEvents { get; set; } + + public bool? EmitAgentResponseUpdateEvents { get; set; } + public HandoffToolCallFilteringBehavior ToolCallFilteringBehavior { get; set; } = HandoffToolCallFilteringBehavior.HandoffOnly; } @@ -250,7 +256,14 @@ internal sealed class HandoffAgentExecutor( } } - allMessages.AddRange(updates.ToAgentResponse().Messages); + AgentResponse agentResponse = updates.ToAgentResponse(); + + if (options.EmitAgentResponseEvents) + { + await context.YieldOutputAsync(agentResponse, cancellationToken).ConfigureAwait(false); + } + + allMessages.AddRange(agentResponse.Messages); roleChanges.ResetUserToAssistantForChangedRoles(); @@ -259,7 +272,7 @@ internal sealed class HandoffAgentExecutor( async Task AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken) { updates.Add(update); - if (message.TurnToken.EmitEvents is true) + if (message.TurnToken.ShouldEmitStreamingEvents(options.EmitAgentResponseUpdateEvents)) { await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowChatHistoryProvider.cs index 2815ed99f0..69c09abb27 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowChatHistoryProvider.cs @@ -43,7 +43,7 @@ internal sealed class WorkflowChatHistoryProvider : ChatHistoryProvider => this._sessionState.GetOrInitializeState(session).Messages.AddRange(messages); protected override ValueTask> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default) - => new(this._sessionState.GetOrInitializeState(context.Session).Messages); + => new(this._sessionState.GetOrInitializeState(context.Session).Messages.AsReadOnly()); protected override ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default) { @@ -62,6 +62,12 @@ internal sealed class WorkflowChatHistoryProvider : ChatHistoryProvider } } + public IEnumerable GetAllMessages(AgentSession session) + { + var state = this._sessionState.GetOrInitializeState(session); + return state.Messages.AsReadOnly(); + } + public void UpdateBookmark(AgentSession session) { var state = this._sessionState.GetOrInitializeState(session); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs index 7679123970..295c08ceeb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs @@ -119,13 +119,17 @@ internal sealed class WorkflowHostAgent : AIAgent MessageMerger merger = new(); await foreach (AgentResponseUpdate update in workflowSession.InvokeStageAsync(cancellationToken) - .ConfigureAwait(false) - .WithCancellation(cancellationToken)) + .ConfigureAwait(false) + .WithCancellation(cancellationToken)) { merger.AddUpdate(update); } - return merger.ComputeMerged(workflowSession.LastResponseId!, this.Id, this.Name); + AgentResponse response = merger.ComputeMerged(workflowSession.LastResponseId!, this.Id, this.Name); + workflowSession.ChatHistoryProvider.AddMessages(workflowSession, response.Messages); + workflowSession.ChatHistoryProvider.UpdateBookmark(workflowSession); + + return response; } protected override async @@ -138,11 +142,18 @@ internal sealed class WorkflowHostAgent : AIAgent await this.ValidateWorkflowAsync().ConfigureAwait(false); WorkflowSession workflowSession = await this.UpdateSessionAsync(messages, session, cancellationToken).ConfigureAwait(false); + MessageMerger merger = new(); + await foreach (AgentResponseUpdate update in workflowSession.InvokeStageAsync(cancellationToken) .ConfigureAwait(false) .WithCancellation(cancellationToken)) { + merger.AddUpdate(update); yield return update; } + + AgentResponse response = merger.ComputeMerged(workflowSession.LastResponseId!, this.Id, this.Name); + workflowSession.ChatHistoryProvider.AddMessages(workflowSession, response.Messages); + workflowSession.ChatHistoryProvider.UpdateBookmark(workflowSession); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs index db3d299ee9..715c232e7d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs @@ -131,7 +131,7 @@ internal sealed class WorkflowSession : AgentSession { Throw.IfNullOrEmpty(parts); - AgentResponseUpdate update = new(ChatRole.Assistant, parts) + return new(ChatRole.Assistant, parts) { CreatedAt = DateTimeOffset.UtcNow, MessageId = Guid.NewGuid().ToString("N"), @@ -139,27 +139,19 @@ internal sealed class WorkflowSession : AgentSession ResponseId = responseId, RawRepresentation = raw }; - - this.ChatHistoryProvider.AddMessages(this, update.ToChatMessage()); - - return update; } public AgentResponseUpdate CreateUpdate(string responseId, object raw, ChatMessage message) { Throw.IfNull(message); - AgentResponseUpdate update = new(message.Role, message.Contents) + return new(message.Role, message.Contents) { CreatedAt = message.CreatedAt ?? DateTimeOffset.UtcNow, MessageId = message.MessageId ?? Guid.NewGuid().ToString("N"), ResponseId = responseId, RawRepresentation = raw }; - - this.ChatHistoryProvider.AddMessages(this, update.ToChatMessage()); - - return update; } private async ValueTask CreateOrResumeRunAsync(List messages, CancellationToken cancellationToken = default) @@ -328,111 +320,104 @@ internal sealed class WorkflowSession : AgentSession IAsyncEnumerable InvokeStageAsync( [EnumeratorCancellation] CancellationToken cancellationToken = default) { - try - { - this.LastResponseId = Guid.NewGuid().ToString("N"); - List messages = this.ChatHistoryProvider.GetFromBookmark(this).ToList(); + this.LastResponseId = Guid.NewGuid().ToString("N"); + List messages = this.ChatHistoryProvider.GetFromBookmark(this).ToList(); + + ResumeRunResult resumeResult = + await this.CreateOrResumeRunAsync(messages, cancellationToken).ConfigureAwait(false); - ResumeRunResult resumeResult = - await this.CreateOrResumeRunAsync(messages, cancellationToken).ConfigureAwait(false); #pragma warning disable CA2007 // Analyzer misfiring. - await using StreamingRun run = resumeResult.Run; + await using StreamingRun run = resumeResult.Run; #pragma warning restore CA2007 - ResumeDispatchInfo dispatchInfo = resumeResult.DispatchInfo; + ResumeDispatchInfo dispatchInfo = resumeResult.DispatchInfo; - // Send a TurnToken to the start executor unless the only activity is an external - // response directed at the start executor itself (which self-emits a TurnToken via - // ContinueTurnAsync). Non-start executors (e.g., RequestInfoExecutor) do not emit - // TurnTokens after processing responses, so the session must always provide one. - bool shouldSendTurnToken = - !dispatchInfo.HasMatchedExternalResponses - || !dispatchInfo.HasMatchedResponseForStartExecutor; - if (shouldSendTurnToken) - { - await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false); - } - await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken) + // Send a TurnToken to the start executor unless the only activity is an external + // response directed at the start executor itself (which self-emits a TurnToken via + // ContinueTurnAsync). Non-start executors (e.g., RequestInfoExecutor) do not emit + // TurnTokens after processing responses, so the session must always provide one. + bool shouldSendTurnToken = + !dispatchInfo.HasMatchedExternalResponses + || !dispatchInfo.HasMatchedResponseForStartExecutor; + if (shouldSendTurnToken) + { + await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false); + } + await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken) .ConfigureAwait(false) .WithCancellation(cancellationToken)) - { - switch (evt) - { - case AgentResponseUpdateEvent agentUpdate: - yield return agentUpdate.Update; - break; - - case RequestInfoEvent requestInfo: - AIContent requestContent = CreateRequestContentForDelivery(requestInfo.Request); - - // Track the pending request so we can convert incoming responses back to ExternalResponse. - // External callers respond using the workflow-facing request ID, which is always RequestId. - this.AddPendingRequest(requestInfo.Request.RequestId, requestInfo.Request); - - AgentResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, requestContent); - yield return update; - break; - - case WorkflowErrorEvent workflowError: - Exception? exception = workflowError.Exception; - if (exception is TargetInvocationException tie && tie.InnerException != null) - { - exception = tie.InnerException; - } - - if (exception != null) - { - string message = this._includeExceptionDetails - ? exception.Message - : "An error occurred while executing the workflow."; - - ErrorContent errorContent = new(message); - yield return this.CreateUpdate(this.LastResponseId, evt, errorContent); - } - - break; - - case SuperStepCompletedEvent stepCompleted: - this.LastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint; - goto default; - - case WorkflowOutputEvent output: - IEnumerable? updateMessages = output.Data switch - { - IEnumerable chatMessages => chatMessages, - ChatMessage chatMessage => [chatMessage], - _ => null - }; - - if (!this._includeWorkflowOutputsInResponse || updateMessages == null) - { - goto default; - } - - foreach (ChatMessage message in updateMessages) - { - yield return this.CreateUpdate(this.LastResponseId, evt, message); - } - break; - - default: - // Emit all other workflow events for observability (DevUI, logging, etc.) - yield return new AgentResponseUpdate(ChatRole.Assistant, []) - { - CreatedAt = DateTimeOffset.UtcNow, - MessageId = Guid.NewGuid().ToString("N"), - Role = ChatRole.Assistant, - ResponseId = this.LastResponseId, - RawRepresentation = evt - }; - break; - } - } - } - finally { - // Do we want to try to undo the step, and not update the bookmark? - this.ChatHistoryProvider.UpdateBookmark(this); + switch (evt) + { + case AgentResponseUpdateEvent agentUpdate: + yield return agentUpdate.Update; + break; + + case RequestInfoEvent requestInfo: + AIContent requestContent = CreateRequestContentForDelivery(requestInfo.Request); + + // Track the pending request so we can convert incoming responses back to ExternalResponse. + // External callers respond using the workflow-facing request ID, which is always RequestId. + this.AddPendingRequest(requestInfo.Request.RequestId, requestInfo.Request); + + AgentResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, requestContent); + yield return update; + break; + + case WorkflowErrorEvent workflowError: + Exception? exception = workflowError.Exception; + if (exception is TargetInvocationException tie && tie.InnerException != null) + { + exception = tie.InnerException; + } + + if (exception != null) + { + string message = this._includeExceptionDetails + ? exception.Message + : "An error occurred while executing the workflow."; + + ErrorContent errorContent = new(message); + yield return this.CreateUpdate(this.LastResponseId, evt, errorContent); + } + + break; + + case SuperStepCompletedEvent stepCompleted: + this.LastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint; + goto default; + + case WorkflowOutputEvent output: + IEnumerable? updateMessages = output.Data switch + { + IEnumerable chatMessages => chatMessages, + ChatMessage chatMessage => [chatMessage], + _ => null + }; + + if (!this._includeWorkflowOutputsInResponse || updateMessages == null) + { + goto default; + } + + foreach (ChatMessage message in updateMessages) + { + yield return this.CreateUpdate(this.LastResponseId, evt, message); + } + break; + + default: + // Emit all other workflow events for observability (DevUI, logging, etc.) + yield return new AgentResponseUpdate(ChatRole.Assistant, []) + { + CreatedAt = DateTimeOffset.UtcNow, + MessageId = Guid.NewGuid().ToString("N"), + Role = ChatRole.Assistant, + ResponseId = this.LastResponseId, + RawRepresentation = evt + }; + break; + } } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs index 063bd77cda..9cd9eb45e6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs @@ -10,20 +10,8 @@ using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.UnitTests; -public class AIAgentHostExecutorTests +public class AIAgentHostExecutorTests : AIAgentHostingExecutorTestsBase { - private const string TestAgentId = nameof(TestAgentId); - private const string TestAgentName = nameof(TestAgentName); - - private static readonly string[] s_messageStrings = [ - "", - "Hello world!", - "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", - "Quisque dignissim ante odio, at facilisis orci porta a. Duis mi augue, fringilla eu egestas a, pellentesque sed lacus." - ]; - - private static List TestMessages => TestReplayAgent.ToChatMessages(s_messageStrings); - [Theory] [InlineData(null, null)] [InlineData(null, true)] @@ -50,30 +38,7 @@ public class AIAgentHostExecutorTests bool expectingEvents = turnSetting ?? executorSetting ?? false; AgentResponseUpdateEvent[] updates = testContext.Events.OfType().ToArray(); - if (expectingEvents) - { - // The way TestReplayAgent is set up, it will emit one update per non-empty AIContent - List expectedUpdateContents = TestMessages.SelectMany(message => message.Contents).ToList(); - - updates.Should().HaveCount(expectedUpdateContents.Count); - for (int i = 0; i < updates.Length; i++) - { - AgentResponseUpdateEvent updateEvent = updates[i]; - AIContent expectedUpdateContent = expectedUpdateContents[i]; - - updateEvent.ExecutorId.Should().Be(agent.GetDescriptiveId()); - - AgentResponseUpdate update = updateEvent.Update; - update.AuthorName.Should().Be(TestAgentName); - update.AgentId.Should().Be(TestAgentId); - update.Contents.Should().HaveCount(1); - update.Contents[0].Should().BeEquivalentTo(expectedUpdateContent); - } - } - else - { - updates.Should().BeEmpty(); - } + CheckResponseUpdateEventsAgainstTestMessages(updates, expectingEvents, agent.GetDescriptiveId()); } [Theory] @@ -92,30 +57,7 @@ public class AIAgentHostExecutorTests // Assert AgentResponseEvent[] updates = testContext.Events.OfType().ToArray(); - if (executorSetting) - { - updates.Should().HaveCount(1); - - AgentResponseEvent responseEvent = updates[0]; - responseEvent.ExecutorId.Should().Be(agent.GetDescriptiveId()); - - AgentResponse response = responseEvent.Response; - response.AgentId.Should().Be(TestAgentId); - response.Messages.Should().HaveCount(TestMessages.Count - 1); - - for (int i = 0; i < response.Messages.Count; i++) - { - ChatMessage responseMessage = response.Messages[i]; - ChatMessage expectedMessage = TestMessages[i + 1]; // Skip the first empty message - - responseMessage.AuthorName.Should().Be(TestAgentName); - responseMessage.Text.Should().Be(expectedMessage.Text); - } - } - else - { - updates.Should().BeEmpty(); - } + CheckResponseEventsAgainstTestMessages(updates, expectingResponse: executorSetting, agent.GetDescriptiveId()); } private static ChatMessage UserMessage => new(ChatRole.User, "Hello from User!") { AuthorName = "User" }; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostingExecutorTestsBase.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostingExecutorTestsBase.cs new file mode 100644 index 0000000000..2285074ce3 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostingExecutorTestsBase.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +public abstract class AIAgentHostingExecutorTestsBase +{ + protected const string TestAgentId = nameof(TestAgentId); + protected const string TestAgentName = nameof(TestAgentName); + + private static readonly string[] s_messageStrings = [ + "", + "Hello world!", + "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + "Quisque dignissim ante odio, at facilisis orci porta a. Duis mi augue, fringilla eu egestas a, pellentesque sed lacus." + ]; + + protected static List TestMessages => TestReplayAgent.ToChatMessages(s_messageStrings); + + protected static void CheckResponseUpdateEventsAgainstTestMessages(AgentResponseUpdateEvent[] updates, bool expectingEvents, string expectedExecutorId) + { + if (expectingEvents) + { + // The way TestReplayAgent is set up, it will emit one update per non-empty AIContent + List expectedUpdateContents = TestMessages.SelectMany(message => message.Contents).ToList(); + + updates.Should().HaveCount(expectedUpdateContents.Count); + for (int i = 0; i < updates.Length; i++) + { + AgentResponseUpdateEvent updateEvent = updates[i]; + AIContent expectedUpdateContent = expectedUpdateContents[i]; + + updateEvent.ExecutorId.Should().Be(expectedExecutorId); + + AgentResponseUpdate update = updateEvent.Update; + update.AuthorName.Should().Be(TestAgentName); + update.AgentId.Should().Be(TestAgentId); + update.Contents.Should().HaveCount(1); + update.Contents[0].Should().BeEquivalentTo(expectedUpdateContent); + } + } + else + { + updates.Should().BeEmpty(); + } + } + + protected static void CheckResponseEventsAgainstTestMessages(AgentResponseEvent[] updates, bool expectingResponse, string expectedExecutorId) + { + if (expectingResponse) + { + updates.Should().HaveCount(1); + + AgentResponseEvent responseEvent = updates[0]; + responseEvent.ExecutorId.Should().Be(expectedExecutorId); + + AgentResponse response = responseEvent.Response; + response.AgentId.Should().Be(TestAgentId); + response.Messages.Should().HaveCount(TestMessages.Count - 1); + + for (int i = 0; i < response.Messages.Count; i++) + { + ChatMessage responseMessage = response.Messages[i]; + ChatMessage expectedMessage = TestMessages[i + 1]; // Skip the first empty message + + responseMessage.AuthorName.Should().Be(TestAgentName); + responseMessage.Text.Should().Be(expectedMessage.Text); + } + } + else + { + updates.Should().BeEmpty(); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs new file mode 100644 index 0000000000..8bdbe23c5f --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Specialized; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +public class HandoffAgentExecutorTests : AIAgentHostingExecutorTestsBase +{ + [Theory] + [InlineData(null, null)] + [InlineData(null, true)] + [InlineData(null, false)] + [InlineData(true, null)] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, null)] + [InlineData(false, true)] + [InlineData(false, false)] + public async Task Test_HandoffAgentExecutor_EmitsStreamingUpdatesIFFConfiguredAsync(bool? executorSetting, bool? turnSetting) + { + // Arrange + TestRunContext testContext = new(); + TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName); + + HandoffAgentExecutorOptions options = new("", + emitAgentResponseEvents: false, + emitAgentResponseUpdateEvents: executorSetting, + HandoffToolCallFilteringBehavior.None); + + HandoffAgentExecutor executor = new(agent, options); + testContext.ConfigureExecutor(executor); + + // Act + HandoffState message = new(new(turnSetting), null, []); + await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id)); + + // Assert + bool expectingStreamingUpdates = turnSetting ?? executorSetting ?? false; + + AgentResponseUpdateEvent[] updates = testContext.Events.OfType().ToArray(); + CheckResponseUpdateEventsAgainstTestMessages(updates, expectingStreamingUpdates, agent.GetDescriptiveId()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Test_HandoffAgentExecutor_EmitsResponseIFFConfiguredAsync(bool executorSetting) + { + // Arrange + TestRunContext testContext = new(); + TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName); + + HandoffAgentExecutorOptions options = new("", + emitAgentResponseEvents: executorSetting, + emitAgentResponseUpdateEvents: false, + HandoffToolCallFilteringBehavior.None); + + HandoffAgentExecutor executor = new(agent, options); + testContext.ConfigureExecutor(executor); + + // Act + HandoffState message = new(new(false), null, []); + await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id)); + + // Assert + AgentResponseEvent[] updates = testContext.Events.OfType().ToArray(); + CheckResponseEventsAgainstTestMessages(updates, expectingResponse: executorSetting, agent.GetDescriptiveId()); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs index 1920a57cb1..7bf00c4fb9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs @@ -206,7 +206,7 @@ internal sealed class TurnTrackingStartExecutor : ChatProtocolExecutor } } -public class WorkflowHostSmokeTests +public class WorkflowHostSmokeTests : AIAgentHostingExecutorTestsBase { private sealed class AlwaysFailsAIAgent(bool failByThrowing) : AIAgent { @@ -731,4 +731,70 @@ public class WorkflowHostSmokeTests .Should() .BeEmpty(); } + + private async Task Run_AsAgent_OutgoingMessagesInHistoryAsync(Workflow workflow, bool runAsync) + { + // Arrange + AIAgent workflowAgent = workflow.AsAIAgent(); + + // Act + AgentSession session = await workflowAgent.CreateSessionAsync(); + AgentResponse response; + if (runAsync) + { + List updates = []; + await foreach (AgentResponseUpdate update in workflowAgent.RunStreamingAsync(session)) + { + // Skip WorkflowEvent updates, which do not get persisted in ChatHistory; we cannot skip + // them after because of a deleterious interaction with .ToAgentResponse() due to the + // empty initial message (which is created without a MessageId). When running through the + // message merger, it does the right thing internally. + if (!string.IsNullOrEmpty(update.Text)) + { + updates.Add(update); + } + } + + response = updates.ToAgentResponse(); + } + else + { + response = await workflowAgent.RunAsync(session); + } + + // Assert + WorkflowSession workflowSession = session.Should().BeOfType().Subject; + + ChatMessage[] responseMessages = response.Messages.Where(message => message.Contents.Any()) + .ToArray(); + + ChatMessage[] sessionMessages = workflowSession.ChatHistoryProvider.GetAllMessages(workflowSession) + .ToArray(); + + // Since we never sent an incoming message, the expectation is that there should be nothing in the session + // except the response + responseMessages.Should().BeEquivalentTo(sessionMessages, options => options.WithStrictOrdering()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public Task Test_SingleAgent_AsAgent_OutgoingMessagesInHistoryAsync(bool runAsync) + { + // Arrange + TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName); + Workflow singleAgentWorkflow = new WorkflowBuilder(agent).Build(); + return this.Run_AsAgent_OutgoingMessagesInHistoryAsync(singleAgentWorkflow, runAsync); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public Task Test_Handoffs_AsAgent_OutgoingMessagesInHistoryAsync(bool runAsync) + { + // Arrange + TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName); + Workflow handoffWorkflow = new HandoffsWorkflowBuilder(agent).Build(); + return this.Run_AsAgent_OutgoingMessagesInHistoryAsync(handoffWorkflow, runAsync); + } } From 0bdcaa5c074caf680e75c1987539eb9af87d6a6d Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 25 Mar 2026 18:53:44 -0400 Subject: [PATCH 02/22] fix: Re-enable the "retrieve object" check in StateManager (#1881) --- .../Microsoft.Agents.AI.Workflows/Execution/StateManager.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StateManager.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StateManager.cs index 81ffedc6af..3e34797bf0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StateManager.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StateManager.cs @@ -106,8 +106,7 @@ internal sealed class StateManager if (typeof(T) == typeof(object)) { // Reading as object will break across serialize/deserialize boundaries, e.g. checkpointing, distributed runtime, etc. - // Disabled pending upstream updates for this change; see https://github.com/microsoft/agent-framework/issues/1369 - //throw new NotSupportedException("Reading state as 'object' is not supported. Use 'PortableValue' instead for variants."); + throw new NotSupportedException("Reading state as 'object' is not supported. Use 'PortableValue' instead for variants."); } Throw.IfNullOrEmpty(key); From c1435ac2010002fba57c60cbb8227e27c94bc92f Mon Sep 17 00:00:00 2001 From: Giles Odigwe <79032838+giles17@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:08:47 -0700 Subject: [PATCH 03/22] Python: Fix A2AAgent to surface message content from in-progress TaskStatusUpdateEvents (#4798) * Fix A2AAgent dropping message content from in-progress TaskStatusUpdateEvents (#4783) _updates_from_task() returned [] for working-state tasks when background=False, silently discarding all intermediate message content from task.status.message. Now extracts and yields message parts from in-progress status updates during streaming. Also fixed MockA2AClient.send_message to yield all queued responses (enabling multi-event streaming tests) and added text parameter to add_in_progress_task_response for tests that need status messages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix: gate intermediate status updates behind emit_intermediate flag and add missing test coverage - Add emit_intermediate parameter to _updates_from_task and _map_a2a_stream - Thread stream flag from run() so only streaming callers see intermediate updates - Add IN_PROGRESS_TASK_STATES guard to emit_intermediate condition - Add role parameter to test helper add_in_progress_task_response - Add clarifying comment on MockA2AClient.send_message batch semantics - Add tests for user role mapping, background precedence, non-streaming behavior, terminal task with no artifacts, and empty parts edge case Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../a2a/agent_framework_a2a/_agent.py | 55 +++++- python/packages/a2a/tests/test_a2a_agent.py | 157 +++++++++++++++++- 2 files changed, 202 insertions(+), 10 deletions(-) diff --git a/python/packages/a2a/agent_framework_a2a/_agent.py b/python/packages/a2a/agent_framework_a2a/_agent.py index d016caae7c..4b6d9cc19b 100644 --- a/python/packages/a2a/agent_framework_a2a/_agent.py +++ b/python/packages/a2a/agent_framework_a2a/_agent.py @@ -313,6 +313,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): self._map_a2a_stream( a2a_stream, background=background, + emit_intermediate=stream, session=provider_session, session_context=session_context, ), @@ -327,6 +328,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): a2a_stream: AsyncIterable[A2AStreamItem], *, background: bool = False, + emit_intermediate: bool = False, session: AgentSession | None = None, session_context: SessionContext | None = None, ) -> AsyncIterable[AgentResponseUpdate]: @@ -339,6 +341,10 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): background: When False, in-progress task updates are silently consumed (the stream keeps iterating until a terminal state). When True, they are yielded with a continuation token. + emit_intermediate: When True, in-progress status updates that + carry message content are yielded to the caller. Typically + set for streaming callers so non-streaming consumers only + receive terminal task outputs. session: The agent session for context providers. session_context: The session context for context providers. """ @@ -373,7 +379,11 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): yield update elif isinstance(item, tuple) and len(item) == 2 and isinstance(item[0], Task): task, _update_event = item - for update in self._updates_from_task(task, background=background): + for update in self._updates_from_task( + task, + background=background, + emit_intermediate=emit_intermediate, + ): all_updates.append(update) yield update else: @@ -389,15 +399,26 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): # Task helpers # ------------------------------------------------------------------ - def _updates_from_task(self, task: Task, *, background: bool = False) -> list[AgentResponseUpdate]: + def _updates_from_task( + self, + task: Task, + *, + background: bool = False, + emit_intermediate: bool = False, + ) -> list[AgentResponseUpdate]: """Convert an A2A Task into AgentResponseUpdate(s). Terminal tasks produce updates from their artifacts/history. - In-progress tasks produce a continuation token update only when - ``background=True``; otherwise they are silently skipped so the - caller keeps consuming the stream until completion. + In-progress tasks produce a continuation token update when + ``background=True``. When ``emit_intermediate=True`` (typically + set for streaming callers), any message content attached to an + in-progress status update is surfaced; otherwise the update is + silently skipped so the caller keeps consuming the stream until + completion. """ - if task.status.state in TERMINAL_TASK_STATES: + status = task.status + + if status.state in TERMINAL_TASK_STATES: task_messages = self._parse_messages_from_task(task) if task_messages: return [ @@ -412,7 +433,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): ] return [AgentResponseUpdate(contents=[], role="assistant", response_id=task.id, raw_representation=task)] - if background and task.status.state in IN_PROGRESS_TASK_STATES: + if background and status.state in IN_PROGRESS_TASK_STATES: token = self._build_continuation_token(task) return [ AgentResponseUpdate( @@ -424,6 +445,26 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): ) ] + # Surface message content from in-progress status updates (e.g. working state) + # Only emitted when the caller opts in (streaming), so non-streaming + # consumers keep receiving only terminal task outputs. + if ( + emit_intermediate + and status.state in IN_PROGRESS_TASK_STATES + and status.message is not None + and status.message.parts + ): + contents = self._parse_contents_from_a2a(status.message.parts) + if contents: + return [ + AgentResponseUpdate( + contents=contents, + role="assistant" if status.message.role == A2ARole.agent else "user", + response_id=task.id, + raw_representation=task, + ) + ] + return [] @staticmethod diff --git a/python/packages/a2a/tests/test_a2a_agent.py b/python/packages/a2a/tests/test_a2a_agent.py index b8633938fc..0d81179cd1 100644 --- a/python/packages/a2a/tests/test_a2a_agent.py +++ b/python/packages/a2a/tests/test_a2a_agent.py @@ -91,9 +91,18 @@ class MockA2AClient: task_id: str, context_id: str = "test-context", state: TaskState = TaskState.working, + text: str | None = None, + role: A2ARole = A2ARole.agent, ) -> None: """Add a mock in-progress Task response (non-terminal).""" - status = TaskStatus(state=state, message=None) + message = None + if text is not None: + message = A2AMessage( + message_id=str(uuid4()), + role=role, + parts=[Part(root=TextPart(text=text))], + ) + status = TaskStatus(state=state, message=message) task = Task(id=task_id, context_id=context_id, status=status) client_event = (task, None) self.responses.append(client_event) @@ -102,9 +111,10 @@ class MockA2AClient: """Mock send_message method that yields responses.""" self.call_count += 1 - if self.responses: - response = self.responses.pop(0) + # All queued responses are delivered as a single streaming batch per call. + for response in self.responses: yield response + self.responses.clear() async def resubscribe(self, request: Any) -> AsyncIterator[Any]: """Mock resubscribe method that yields responses.""" @@ -1039,3 +1049,144 @@ async def test_run_with_continuation_token_does_not_require_messages(mock_a2a_cl # endregion + +# region Streaming with in-progress message content + + +async def test_streaming_working_updates_yield_message_content( + a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient +) -> None: + """Test that streaming working updates with status.message yield content.""" + mock_a2a_client.add_in_progress_task_response("task-w", context_id="ctx-w", text="Processing step 1...") + mock_a2a_client.add_in_progress_task_response("task-w", context_id="ctx-w", text="Processing step 2...") + mock_a2a_client.add_task_response("task-w", [{"id": "art-w", "content": "Final result"}]) + + updates: list[AgentResponseUpdate] = [] + async for update in a2a_agent.run("Hello", stream=True): + updates.append(update) + + assert len(updates) == 3 + assert updates[0].contents[0].text == "Processing step 1..." + assert updates[1].contents[0].text == "Processing step 2..." + assert updates[2].contents[0].text == "Final result" + + +async def test_streaming_single_working_update_with_message( + a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient +) -> None: + """Test that a single working update with message content is not dropped.""" + mock_a2a_client.add_in_progress_task_response("task-s", context_id="ctx-s", text="Thinking...") + mock_a2a_client.add_task_response("task-s", [{"id": "art-s", "content": "Done"}]) + + updates: list[AgentResponseUpdate] = [] + async for update in a2a_agent.run("Hello", stream=True): + updates.append(update) + + assert len(updates) == 2 + assert updates[0].contents[0].text == "Thinking..." + assert updates[0].role == "assistant" + assert updates[1].contents[0].text == "Done" + + +async def test_streaming_working_update_without_message_is_skipped( + a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient +) -> None: + """Test that working updates without status.message are still silently skipped.""" + mock_a2a_client.add_in_progress_task_response("task-n", context_id="ctx-n") + mock_a2a_client.add_task_response("task-n", [{"id": "art-n", "content": "Result"}]) + + updates: list[AgentResponseUpdate] = [] + async for update in a2a_agent.run("Hello", stream=True): + updates.append(update) + + assert len(updates) == 1 + assert updates[0].contents[0].text == "Result" + + +async def test_streaming_working_update_user_role_mapping(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None: + """Test that A2ARole.user in status message maps to role='user'.""" + mock_a2a_client.add_in_progress_task_response("task-u", context_id="ctx-u", text="User echo", role=A2ARole.user) + mock_a2a_client.add_task_response("task-u", [{"id": "art-u", "content": "Done"}]) + + updates: list[AgentResponseUpdate] = [] + async for update in a2a_agent.run("Hello", stream=True): + updates.append(update) + + assert len(updates) == 2 + assert updates[0].contents[0].text == "User echo" + assert updates[0].role == "user" + + +async def test_background_with_status_message_yields_continuation_token( + a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient +) -> None: + """Test that background=True takes precedence over status message content.""" + mock_a2a_client.add_in_progress_task_response("task-bg", context_id="ctx-bg", text="Should be ignored") + + updates: list[AgentResponseUpdate] = [] + async for update in a2a_agent.run("Hello", stream=True, background=True): + updates.append(update) + + assert len(updates) == 1 + assert updates[0].continuation_token is not None + assert updates[0].continuation_token["task_id"] == "task-bg" + assert updates[0].contents == [] + + +async def test_non_streaming_does_not_surface_intermediate_messages( + a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient +) -> None: + """Test that run(stream=False) does not include intermediate status messages.""" + mock_a2a_client.add_in_progress_task_response("task-ns", context_id="ctx-ns", text="Intermediate") + mock_a2a_client.add_task_response("task-ns", [{"id": "art-ns", "content": "Final"}]) + + response = await a2a_agent.run("Hello") + + assert len(response.messages) == 1 + assert response.messages[0].text == "Final" + + +async def test_terminal_no_artifacts_after_working_with_content( + a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient +) -> None: + """Test that a terminal task with no artifacts after working-state messages does not re-emit the working content.""" + mock_a2a_client.add_in_progress_task_response("task-t", context_id="ctx-t", text="Working on it...") + # Terminal task with no artifacts and no history + status = TaskStatus(state=TaskState.completed, message=None) + task = Task(id="task-t", context_id="ctx-t", status=status) + mock_a2a_client.responses.append((task, None)) + + updates: list[AgentResponseUpdate] = [] + async for update in a2a_agent.run("Hello", stream=True): + updates.append(update) + + assert len(updates) == 2 + assert updates[0].contents[0].text == "Working on it..." + # Terminal task with no artifacts yields an empty-contents update + assert updates[1].contents == [] + + +async def test_streaming_working_update_with_empty_parts_is_skipped( + a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient +) -> None: + """Test that a working update with status.message but empty parts list is skipped.""" + # Construct a message with an empty parts list (distinct from message=None) + message = A2AMessage( + message_id=str(uuid4()), + role=A2ARole.agent, + parts=[], + ) + status = TaskStatus(state=TaskState.working, message=message) + task = Task(id="task-ep", context_id="ctx-ep", status=status) + mock_a2a_client.responses.append((task, None)) + mock_a2a_client.add_task_response("task-ep", [{"id": "art-ep", "content": "Result"}]) + + updates: list[AgentResponseUpdate] = [] + async for update in a2a_agent.run("Hello", stream=True): + updates.append(update) + + assert len(updates) == 1 + assert updates[0].contents[0].text == "Result" + + +# endregion From dc27740f1a752360426b8ef29efd8ec259325e1b Mon Sep 17 00:00:00 2001 From: Giles Odigwe <79032838+giles17@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:09:22 -0700 Subject: [PATCH 04/22] Python: Fix streaming path to emit mcp_server_tool_result on output_item.done instead of output_item.added (#4821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix streaming path to deliver mcp_server_tool_result content (#4814) Remove premature mcp_server_tool_result emission from the response.output_item.added/mcp_call handler — at that point the MCP server has not yet responded and output is always None. Add a handler for response.mcp_call.completed that emits mcp_server_tool_result with the actual tool output, matching the non-streaming path behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix streaming path to deliver mcp_server_tool_result content (#4814) Stop eagerly emitting mcp_server_tool_result on response.output_item.added (when output is always None). Instead, handle response.output_item.done for mcp_call items, which carries the full McpCall with populated output. This matches the non-streaming path which guards with 'if item.output is not None' before emitting the result. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix test docstring to match actual implementation event name Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review: call_id fallback and raw_representation consistency (#4814) - Add call_id fallback in response.output_item.done mcp_call handler to match the output_item.added handler pattern - Use done_item instead of event for raw_representation to keep consistent with other output_item branches and non-streaming path - Add test for call_id fallback when id attribute is missing - Add raw_representation assertions to existing done handler tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review: call_id fallback for non-streaming path and test coverage (#4814) - Apply defensive call_id fallback (getattr with id/call_id/empty) to non-streaming mcp_call path for consistency with streaming path - Add raw_representation assertion to call_id fallback test - Add test for empty-string fallback when neither id nor call_id exist Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agent_framework_openai/_chat_client.py | 39 +++--- .../tests/openai/test_openai_chat_client.py | 129 ++++++++++++++++-- 2 files changed, 136 insertions(+), 32 deletions(-) diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index e1e40c87c0..86af86895e 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -1728,7 +1728,7 @@ class RawOpenAIChatClient( # type: ignore[misc] ) ) case "mcp_call": - call_id = item.id + call_id = getattr(item, "id", None) or getattr(item, "call_id", None) or "" contents.append( Content.from_mcp_server_tool_call( call_id=call_id, @@ -2118,27 +2118,7 @@ class RawOpenAIChatClient( # type: ignore[misc] raw_representation=event_item, ) ) - result_output = ( - getattr(event_item, "result", None) - or getattr(event_item, "output", None) - or getattr(event_item, "outputs", None) - ) - parsed_output: list[Content] | None = None - if result_output: - normalized = ( # pyright: ignore[reportUnknownVariableType] - result_output - if isinstance(result_output, Sequence) - and not isinstance(result_output, (str, bytes, MutableMapping)) - else [result_output] - ) - parsed_output = [Content.from_dict(output_item) for output_item in normalized] # pyright: ignore[reportArgumentType,reportUnknownVariableType] - contents.append( - Content.from_mcp_server_tool_result( - call_id=call_id, - output=parsed_output, - raw_representation=event_item, - ) - ) + # Result deferred to response.output_item.done case "code_interpreter_call": # ResponseOutputCodeInterpreterCall call_id = getattr(event_item, "call_id", None) or getattr(event_item, "id", None) outputs: list[Content] = [] @@ -2408,6 +2388,21 @@ class RawOpenAIChatClient( # type: ignore[misc] ) else: logger.debug("Unparsed annotation type in streaming: %s", ann_type) + case "response.output_item.done": + done_item = event.item + if getattr(done_item, "type", None) == "mcp_call": + call_id = getattr(done_item, "id", None) or getattr(done_item, "call_id", None) or "" + output_text = getattr(done_item, "output", None) + parsed_output: list[Content] | None = ( + [Content.from_text(text=output_text)] if isinstance(output_text, str) else None + ) + contents.append( + Content.from_mcp_server_tool_result( + call_id=call_id, + output=parsed_output, + raw_representation=done_item, + ) + ) case _: logger.debug("Unparsed event of type: %s: %s", event.type, event) diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index a29fbf443a..897fe5f913 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -1184,11 +1184,13 @@ def test_parse_response_from_openai_with_mcp_server_tool_result() -> None: assert result_content.output is not None -def test_parse_chunk_from_openai_with_mcp_call_result() -> None: - """Test _parse_chunk_from_openai with MCP call output.""" +def test_parse_chunk_from_openai_with_mcp_call_added_defers_result() -> None: + """Test that response.output_item.added for mcp_call emits only the call, not the result. + + The result is deferred to response.output_item.done. + """ client = OpenAIChatClient(model="test-model", api_key="test-key") - # Mock event with MCP call that has output mock_event = MagicMock() mock_event.type = "response.output_item.added" @@ -1199,8 +1201,9 @@ def test_parse_chunk_from_openai_with_mcp_call_result() -> None: mock_item.name = "fetch_resource" mock_item.server_label = "ResourceServer" mock_item.arguments = {"resource_id": "123"} - # Use proper content structure that _parse_content can handle - mock_item.result = [{"type": "text", "text": "test result"}] + mock_item.result = None + mock_item.output = None + mock_item.outputs = None mock_event.item = mock_item mock_event.output_index = 0 @@ -1209,18 +1212,124 @@ def test_parse_chunk_from_openai_with_mcp_call_result() -> None: update = client._parse_chunk_from_openai(mock_event, options={}, function_call_ids=function_call_ids) - # Should have both call and result in contents - assert len(update.contents) == 2 - call_content, result_content = update.contents + # Should have only the call content — result is deferred + assert len(update.contents) == 1 + call_content = update.contents[0] assert call_content.type == "mcp_server_tool_call" assert call_content.call_id in ["mcp_call_456", "call_456"] assert call_content.tool_name == "fetch_resource" + # No result should be emitted at this point + result_contents = [c for c in update.contents if c.type == "mcp_server_tool_result"] + assert len(result_contents) == 0 + + +def test_parse_chunk_from_openai_with_mcp_output_item_done() -> None: + """Test that response.output_item.done for mcp_call emits mcp_server_tool_result with output.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + mock_event = MagicMock() + mock_event.type = "response.output_item.done" + + mock_item = MagicMock() + mock_item.type = "mcp_call" + mock_item.id = "mcp_call_456" + mock_item.output = "The weather in Seattle is 72F and sunny." + mock_event.item = mock_item + + function_call_ids: dict[int, tuple[str, str]] = {} + + update = client._parse_chunk_from_openai(mock_event, options={}, function_call_ids=function_call_ids) + + assert len(update.contents) == 1 + result_content = update.contents[0] + assert result_content.type == "mcp_server_tool_result" - assert result_content.call_id in ["mcp_call_456", "call_456"] - # Verify the output was parsed + assert result_content.call_id == "mcp_call_456" assert result_content.output is not None + assert len(result_content.output) == 1 + assert result_content.output[0].text == "The weather in Seattle is 72F and sunny." + assert result_content.raw_representation is mock_item + + +def test_parse_chunk_from_openai_with_mcp_output_item_done_no_output() -> None: + """Test that response.output_item.done for mcp_call with no output emits result with None output.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + mock_event = MagicMock() + mock_event.type = "response.output_item.done" + + mock_item = MagicMock() + mock_item.type = "mcp_call" + mock_item.id = "mcp_call_789" + mock_item.output = None + mock_event.item = mock_item + + function_call_ids: dict[int, tuple[str, str]] = {} + + update = client._parse_chunk_from_openai(mock_event, options={}, function_call_ids=function_call_ids) + + assert len(update.contents) == 1 + result_content = update.contents[0] + + assert result_content.type == "mcp_server_tool_result" + assert result_content.call_id == "mcp_call_789" + assert result_content.output is None + assert result_content.raw_representation is mock_item + + +def test_parse_chunk_from_openai_with_mcp_output_item_done_call_id_fallback() -> None: + """Test that response.output_item.done for mcp_call falls back to call_id when id is missing.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + mock_event = MagicMock() + mock_event.type = "response.output_item.done" + + mock_item = MagicMock(spec=[]) + mock_item.type = "mcp_call" + mock_item.call_id = "mcp_fallback_123" + mock_item.output = "fallback result" + mock_event.item = mock_item + + function_call_ids: dict[int, tuple[str, str]] = {} + + update = client._parse_chunk_from_openai(mock_event, options={}, function_call_ids=function_call_ids) + + assert len(update.contents) == 1 + result_content = update.contents[0] + + assert result_content.type == "mcp_server_tool_result" + assert result_content.call_id == "mcp_fallback_123" + assert result_content.output is not None + assert result_content.output[0].text == "fallback result" + assert result_content.raw_representation is mock_item + + +def test_parse_chunk_from_openai_with_mcp_output_item_done_no_id_fallback() -> None: + """Test that response.output_item.done for mcp_call falls back to empty string when neither id nor call_id exist.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + mock_event = MagicMock() + mock_event.type = "response.output_item.done" + + mock_item = MagicMock(spec=[]) + mock_item.type = "mcp_call" + mock_item.output = "some result" + mock_event.item = mock_item + + function_call_ids: dict[int, tuple[str, str]] = {} + + update = client._parse_chunk_from_openai(mock_event, options={}, function_call_ids=function_call_ids) + + assert len(update.contents) == 1 + result_content = update.contents[0] + + assert result_content.type == "mcp_server_tool_result" + assert result_content.call_id == "" + assert result_content.output is not None + assert result_content.output[0].text == "some result" + assert result_content.raw_representation is mock_item def test_prepare_message_for_openai_with_function_approval_response() -> None: From dd3d085539375c38927e6101ac60688b69fa08c1 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:56:10 +0900 Subject: [PATCH 05/22] Python: Include reasoning messages in MESSAGES_SNAPSHOT events (#4844) * Include reasoning messages in MESSAGES_SNAPSHOT (#4843) FlowState now tracks reasoning messages emitted during a run. _emit_text_reasoning() persists reasoning (including encrypted_value) into flow.reasoning_messages, and _build_messages_snapshot() appends them to the final MESSAGES_SNAPSHOT event. Changes: - Add reasoning_messages field to FlowState - Update _emit_text_reasoning() to accept optional flow parameter - Include reasoning_messages in _build_messages_snapshot() - Add 'reasoning' to ALLOWED_AGUI_ROLES so normalize_agui_role() preserves the role through snapshot round-trips - Skip reasoning messages in agui_messages_to_agent_framework() since they are UI-only state and should not be forwarded to LLM providers - Add regression tests for snapshot emission, encrypted value preservation, and multi-turn round-trip with reasoning Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Include reasoning messages in MESSAGES_SNAPSHOT events Fixes #4843 * Fix PR review feedback for reasoning persistence (#4843) - Accumulate reasoning text per message_id (append deltas) instead of storing only the current chunk, matching flow.accumulated_text pattern - Use camelCase encryptedValue in snapshot JSON to match AG-UI protocol conventions (toolCallId, encryptedValue) - Normalize snake_case encrypted_value to encryptedValue in agui_messages_to_snapshot_format for input compatibility - Update normalize_agui_role docstring to include reasoning role - Add tests for incremental reasoning accumulation and key normalization Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #4843: Python: agent-framework-ag-ui: include reasoning messages in MESSAGES_SNAPSHOT --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ag-ui/agent_framework_ag_ui/_agent_run.py | 8 +- .../_message_adapters.py | 9 + .../agent_framework_ag_ui/_run_common.py | 40 ++++- .../ag-ui/agent_framework_ag_ui/_utils.py | 4 +- .../tests/ag_ui/test_message_adapters.py | 91 ++++++++++ python/packages/ag-ui/tests/ag_ui/test_run.py | 155 ++++++++++++++++++ .../packages/ag-ui/tests/ag_ui/test_utils.py | 1 + 7 files changed, 303 insertions(+), 5 deletions(-) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py index 4b00330283..e9ce610b10 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py @@ -684,6 +684,10 @@ def _build_messages_snapshot( } ) + # Add reasoning messages so frontends that reconcile state from + # MESSAGES_SNAPSHOT retain reasoning content after streaming ends. + all_messages.extend(flow.reasoning_messages) + return MessagesSnapshotEvent(messages=all_messages) # type: ignore[arg-type] @@ -1061,7 +1065,9 @@ async def run_agent_stream( # Emit MessagesSnapshotEvent if we have tool calls or results # Feature #5: Suppress intermediate snapshots for predictive tools without confirmation - should_emit_snapshot = flow.pending_tool_calls or flow.tool_results or flow.accumulated_text + should_emit_snapshot = ( + flow.pending_tool_calls or flow.tool_results or flow.accumulated_text or flow.reasoning_messages + ) if should_emit_snapshot: # Check if we should suppress for predictive tool last_tool_name = None diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py b/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py index 2e5294a6b6..5e4fced97c 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py @@ -604,6 +604,10 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Mes # Handle standard tool result messages early (role="tool") to preserve provider invariants # This path maps AG‑UI tool messages to function_result content with the correct tool_call_id role_str = normalize_agui_role(msg.get("role", "user")) + if role_str == "reasoning": + # Reasoning messages are UI-only state carried in MESSAGES_SNAPSHOT. + # They should not be forwarded to the LLM provider. + continue if role_str == "tool": # Prefer explicit tool_call_id fields; fall back to backend fields only if necessary tool_call_id = msg.get("tool_call_id") or msg.get("toolCallId") @@ -1020,6 +1024,11 @@ def agui_messages_to_snapshot_format(messages: list[dict[str, Any]]) -> list[dic elif "toolCallId" not in normalized_msg: normalized_msg["toolCallId"] = "" + # Normalize encrypted_value to encryptedValue for reasoning messages + if normalized_msg.get("role") == "reasoning" and "encrypted_value" in normalized_msg: + normalized_msg["encryptedValue"] = normalized_msg["encrypted_value"] + del normalized_msg["encrypted_value"] + result.append(normalized_msg) return result diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py index 0a9f4cea9c..155f559a94 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py @@ -126,6 +126,8 @@ class FlowState: tool_results: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] tool_calls_ended: set[str] = field(default_factory=set) # pyright: ignore[reportUnknownVariableType] interrupts: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] + reasoning_messages: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] + accumulated_reasoning: dict[str, str] = field(default_factory=dict) # pyright: ignore[reportUnknownVariableType] def get_tool_name(self, call_id: str | None) -> str | None: """Get tool name by call ID.""" @@ -460,7 +462,7 @@ def _emit_mcp_tool_result( return _emit_tool_result_common(content.call_id, raw_output, flow, predictive_handler) -def _emit_text_reasoning(content: Content) -> list[BaseEvent]: +def _emit_text_reasoning(content: Content, flow: FlowState | None = None) -> list[BaseEvent]: """Emit AG-UI reasoning events for text_reasoning content. Uses the protocol-defined reasoning event types so that AG-UI consumers @@ -470,6 +472,10 @@ def _emit_text_reasoning(content: Content) -> list[BaseEvent]: ``content.protected_data`` is present it is emitted as a ``ReasoningEncryptedValueEvent`` so that consumers can persist encrypted reasoning for state continuity without conflating it with display text. + + When *flow* is provided the reasoning message is persisted into + ``flow.reasoning_messages`` so that ``_build_messages_snapshot`` can + include it in the final ``MESSAGES_SNAPSHOT``. """ text = content.text or "" if not text and content.protected_data is None: @@ -498,6 +504,36 @@ def _emit_text_reasoning(content: Content) -> list[BaseEvent]: events.append(ReasoningEndEvent(message_id=message_id)) + # Persist reasoning into flow state for MESSAGES_SNAPSHOT. + # Accumulate reasoning text per message_id, similar to flow.accumulated_text, + # so that incremental deltas build the full reasoning string. + if flow is not None: + if text: + previous_text = flow.accumulated_reasoning.get(message_id, "") + flow.accumulated_reasoning[message_id] = previous_text + text + full_text = flow.accumulated_reasoning.get(message_id, text or "") + + # Update existing reasoning entry for this message_id if present; otherwise append a new one. + existing_entry: dict[str, Any] | None = None + for entry in flow.reasoning_messages: + if isinstance(entry, dict) and entry.get("id") == message_id: + existing_entry = entry + break + + if existing_entry is None: + reasoning_entry: dict[str, Any] = { + "id": message_id, + "role": "reasoning", + "content": full_text, + } + if content.protected_data is not None: + reasoning_entry["encryptedValue"] = content.protected_data + flow.reasoning_messages.append(reasoning_entry) + else: + existing_entry["content"] = full_text + if content.protected_data is not None: + existing_entry["encryptedValue"] = content.protected_data + return events @@ -527,6 +563,6 @@ def _emit_content( if content_type == "mcp_server_tool_result": return _emit_mcp_tool_result(content, flow, predictive_handler) if content_type == "text_reasoning": - return _emit_text_reasoning(content) + return _emit_text_reasoning(content, flow) logger.debug("Skipping unsupported content type in AG-UI emitter: %s", content_type) return [] diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_utils.py b/python/packages/ag-ui/agent_framework_ag_ui/_utils.py index bfda3948ec..c68301f7d2 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_utils.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_utils.py @@ -27,7 +27,7 @@ FRAMEWORK_TO_AGUI_ROLE: dict[str, str] = { "system": "system", } -ALLOWED_AGUI_ROLES: set[str] = {"user", "assistant", "system", "tool"} +ALLOWED_AGUI_ROLES: set[str] = {"user", "assistant", "system", "tool", "reasoning"} def generate_event_id() -> str: @@ -82,7 +82,7 @@ def normalize_agui_role(raw_role: Any) -> str: raw_role: Raw role value from AG-UI message Returns: - Normalized role string (user, assistant, system, or tool) + Normalized role string (user, assistant, system, tool, or reasoning) """ if not isinstance(raw_role, str): return "user" diff --git a/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py b/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py index cc4f1230df..9508b53085 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py +++ b/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py @@ -1669,3 +1669,94 @@ def test_agui_fresh_approval_is_still_processed(): assert len(approval_contents) == 1, "Fresh approval should produce function_approval_response" assert approval_contents[0].approved is True assert approval_contents[0].function_call.name == "get_datetime" + + +class TestReasoningRoundTrip: + """Tests for reasoning message handling in inbound/outbound adapters.""" + + def test_reasoning_skipped_on_inbound(self): + """Reasoning messages from prior snapshot are not forwarded to the LLM.""" + messages_input = [ + {"id": "u1", "role": "user", "content": "Hello"}, + {"id": "r1", "role": "reasoning", "content": "Thinking..."}, + {"id": "a1", "role": "assistant", "content": "Hi there"}, + ] + + result = agui_messages_to_agent_framework(messages_input) + + roles = [m.role if hasattr(m.role, "value") else str(m.role) for m in result] + assert "reasoning" not in roles + assert len(result) == 2 + + def test_reasoning_preserved_in_snapshot_format(self): + """Reasoning messages retain their role through snapshot normalization.""" + messages_input = [ + {"id": "u1", "role": "user", "content": "Hello"}, + {"id": "r1", "role": "reasoning", "content": "Thinking about this..."}, + {"id": "a1", "role": "assistant", "content": "Answer"}, + ] + + result = agui_messages_to_snapshot_format(messages_input) + + reasoning_msgs = [m for m in result if m.get("role") == "reasoning"] + assert len(reasoning_msgs) == 1 + assert reasoning_msgs[0]["content"] == "Thinking about this..." + + def test_reasoning_with_encrypted_value_in_snapshot_format(self): + """Reasoning with encryptedValue passes through snapshot normalization.""" + messages_input = [ + { + "id": "r1", + "role": "reasoning", + "content": "visible", + "encryptedValue": "secret-data", + }, + ] + + result = agui_messages_to_snapshot_format(messages_input) + + assert len(result) == 1 + assert result[0]["role"] == "reasoning" + assert result[0]["encryptedValue"] == "secret-data" + + def test_reasoning_encrypted_value_snake_case_normalized(self): + """Snake-case encrypted_value is normalized to encryptedValue in snapshot format.""" + messages_input = [ + { + "id": "r1", + "role": "reasoning", + "content": "visible", + "encrypted_value": "snake-case-data", + }, + ] + + result = agui_messages_to_snapshot_format(messages_input) + + assert len(result) == 1 + assert result[0]["encryptedValue"] == "snake-case-data" + assert "encrypted_value" not in result[0] + + def test_multi_turn_with_reasoning_in_prior_snapshot(self): + """Second turn with reasoning from prior snapshot does not corrupt messages.""" + messages_input = [ + {"id": "u1", "role": "user", "content": "First question"}, + {"id": "r1", "role": "reasoning", "content": "Prior reasoning"}, + {"id": "a1", "role": "assistant", "content": "First answer"}, + {"id": "u2", "role": "user", "content": "Follow-up question"}, + ] + + result = agui_messages_to_agent_framework(messages_input) + + roles = [m.role if hasattr(m.role, "value") else str(m.role) for m in result] + # Reasoning is filtered out, other messages preserved in order + assert roles == ["user", "assistant", "user"] + # Content not corrupted + texts = [] + for m in result: + for c in m.contents or []: + if hasattr(c, "text") and c.text: + texts.append(c.text) + assert "First question" in texts + assert "First answer" in texts + assert "Follow-up question" in texts + assert "Prior reasoning" not in texts diff --git a/python/packages/ag-ui/tests/ag_ui/test_run.py b/python/packages/ag-ui/tests/ag_ui/test_run.py index ae8c5e85b0..0e5c329ce9 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_run.py +++ b/python/packages/ag-ui/tests/ag_ui/test_run.py @@ -1346,3 +1346,158 @@ class TestEmitContentMcpRouting: assert len(events) == 5 assert isinstance(events[0], ReasoningStartEvent) + + +class TestReasoningInSnapshot: + """Tests for reasoning message inclusion in MESSAGES_SNAPSHOT.""" + + def test_reasoning_persisted_to_flow_state(self): + """_emit_text_reasoning with flow persists reasoning into flow.reasoning_messages.""" + flow = FlowState() + content = Content.from_text_reasoning( + id="reason_persist", + text="Let me think step by step.", + ) + + _emit_text_reasoning(content, flow) + + assert len(flow.reasoning_messages) == 1 + assert flow.reasoning_messages[0]["id"] == "reason_persist" + assert flow.reasoning_messages[0]["role"] == "reasoning" + assert flow.reasoning_messages[0]["content"] == "Let me think step by step." + assert "encryptedValue" not in flow.reasoning_messages[0] + + def test_reasoning_with_encrypted_value_persisted(self): + """Reasoning with protected_data preserves encryptedValue in flow state.""" + flow = FlowState() + content = Content.from_text_reasoning( + id="reason_enc", + text="visible reasoning", + protected_data="encrypted-data-123", + ) + + _emit_text_reasoning(content, flow) + + assert len(flow.reasoning_messages) == 1 + assert flow.reasoning_messages[0]["encryptedValue"] == "encrypted-data-123" + + def test_snapshot_includes_reasoning(self): + """_build_messages_snapshot includes reasoning messages from flow state.""" + from agent_framework_ag_ui._agent_run import _build_messages_snapshot + + flow = FlowState() + flow.accumulated_text = "Here is my answer." + flow.reasoning_messages = [ + {"id": "r1", "role": "reasoning", "content": "Thinking..."}, + ] + + snapshot = _build_messages_snapshot(flow, []) + + roles = [m.get("role") if isinstance(m, dict) else getattr(m, "role", None) for m in snapshot.messages] + assert "reasoning" in roles + + def test_snapshot_preserves_reasoning_encrypted_value(self): + """Snapshot reasoning with encryptedValue is preserved end-to-end.""" + from agent_framework_ag_ui._agent_run import _build_messages_snapshot + + flow = FlowState() + content = Content.from_text_reasoning( + id="reason_e2e", + text="visible", + protected_data="secret-data", + ) + _emit_text_reasoning(content, flow) + + text_content = Content.from_text("Final answer.") + _emit_text(text_content, flow) + + snapshot = _build_messages_snapshot(flow, []) + + reasoning_msgs = [ + m + for m in snapshot.messages + if (m.get("role") if isinstance(m, dict) else getattr(m, "role", None)) == "reasoning" + ] + assert len(reasoning_msgs) == 1 + msg = reasoning_msgs[0] + if isinstance(msg, dict): + assert msg["content"] == "visible" + assert msg["encryptedValue"] == "secret-data" + + def test_emit_content_routes_reasoning_with_flow(self): + """_emit_content passes flow to _emit_text_reasoning for persistence.""" + flow = FlowState() + content = Content.from_text_reasoning(text="routed reasoning") + + _emit_content(content, flow) + + assert len(flow.reasoning_messages) == 1 + assert flow.reasoning_messages[0]["content"] == "routed reasoning" + + def test_reasoning_without_flow_does_not_error(self): + """Calling _emit_text_reasoning without flow still works (backward compat).""" + content = Content.from_text_reasoning(text="no flow") + + events = _emit_text_reasoning(content) + + assert len(events) == 5 + assert isinstance(events[0], ReasoningStartEvent) + + def test_snapshot_reasoning_ordering(self): + """Reasoning messages appear after assistant text in snapshot.""" + from agent_framework_ag_ui._agent_run import _build_messages_snapshot + + flow = FlowState() + reasoning_content = Content.from_text_reasoning(id="r1", text="Thinking...") + _emit_text_reasoning(reasoning_content, flow) + + text_content = Content.from_text("Answer") + _emit_text(text_content, flow) + + snapshot = _build_messages_snapshot(flow, [{"id": "u1", "role": "user", "content": "Hi"}]) + + # user -> assistant text -> reasoning + assert len(snapshot.messages) == 3 + roles = [m.get("role") if isinstance(m, dict) else getattr(m, "role", None) for m in snapshot.messages] + assert roles == ["user", "assistant", "reasoning"] + + def test_reasoning_accumulates_incremental_deltas(self): + """Multiple reasoning deltas with the same id accumulate into one entry.""" + flow = FlowState() + content1 = Content.from_text_reasoning(id="reason_inc", text="First ") + content2 = Content.from_text_reasoning(id="reason_inc", text="second ") + content3 = Content.from_text_reasoning(id="reason_inc", text="third.") + + _emit_text_reasoning(content1, flow) + _emit_text_reasoning(content2, flow) + _emit_text_reasoning(content3, flow) + + assert len(flow.reasoning_messages) == 1 + assert flow.reasoning_messages[0]["id"] == "reason_inc" + assert flow.reasoning_messages[0]["content"] == "First second third." + + def test_reasoning_accumulates_distinct_message_ids(self): + """Reasoning entries with different ids are stored separately.""" + flow = FlowState() + content_a = Content.from_text_reasoning(id="a", text="alpha") + content_b = Content.from_text_reasoning(id="b", text="beta") + + _emit_text_reasoning(content_a, flow) + _emit_text_reasoning(content_b, flow) + + assert len(flow.reasoning_messages) == 2 + assert flow.reasoning_messages[0]["content"] == "alpha" + assert flow.reasoning_messages[1]["content"] == "beta" + + def test_reasoning_encrypted_value_updated_on_later_delta(self): + """encryptedValue is set even when it arrives with a later delta.""" + flow = FlowState() + content1 = Content.from_text_reasoning(id="enc_late", text="part1 ") + content2 = Content.from_text_reasoning(id="enc_late", text="part2", protected_data="encrypted-payload") + + _emit_text_reasoning(content1, flow) + _emit_text_reasoning(content2, flow) + + assert len(flow.reasoning_messages) == 1 + assert flow.reasoning_messages[0]["content"] == "part1 part2" + assert flow.reasoning_messages[0]["encryptedValue"] == "encrypted-payload" diff --git a/python/packages/ag-ui/tests/ag_ui/test_utils.py b/python/packages/ag-ui/tests/ag_ui/test_utils.py index 0f453132f7..f353d2f0a7 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_utils.py +++ b/python/packages/ag-ui/tests/ag_ui/test_utils.py @@ -450,6 +450,7 @@ def test_normalize_agui_role_valid(): assert normalize_agui_role("assistant") == "assistant" assert normalize_agui_role("system") == "system" assert normalize_agui_role("tool") == "tool" + assert normalize_agui_role("reasoning") == "reasoning" def test_normalize_agui_role_invalid(): From efb14cedb12b8e7f7b868921ddec1713e9e21972 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Thu, 26 Mar 2026 08:33:19 +0100 Subject: [PATCH 06/22] Python: Support structuredContent in MCP tool results and fix sampling options type (#4763) * Support MCP sampling tools capability (#4625) Forward systemPrompt, tools, and toolChoice from MCP sampling requests to the chat client's get_response() call. Also advertise the sampling.tools capability to MCP servers when a client is configured. - Pass SamplingCapability with tools support to ClientSession - Convert systemPrompt to instructions in options - Convert MCP Tool objects to FunctionTool instances for options - Map MCP ToolChoice.mode to tool_choice in options - Add tests for all new behaviors and update existing sampling tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix #4625: Support MCP sampling tool with proper typing and structured content - Fix mypy error by typing sampling callback options as ChatOptions[None] instead of dict[str, Any], and importing ChatOptions from _types - Handle structuredContent from CallToolResult in _parse_tool_result_from_mcp, serializing it as JSON text Content when present - Add tests for structuredContent parsing (with and without regular content) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix lint: add author to TODO comment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #4625: remove default=str, add edge-case tests - Remove default=str from json.dumps for structuredContent to fail fast on non-JSON-serializable values instead of silently converting - Add test for non-JSON-serializable structuredContent (TypeError) - Add tests for empty systemPrompt ('') and empty tools list ([]) edge cases in sampling callback - Expand TODO comment noting list[Content] return type constraint for future result_type support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Sanitize sampling callback error to avoid leaking internals (#4625) Log exception details at DEBUG level instead of including them in the ErrorData message returned to the MCP server, which may be untrusted. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #4625: move params to options, restore error info - Remove stale TODO comment about response_format (ChatOptions already has it) - Restore {ex} in sampling callback error message for useful debugging info - Set structuredContent as additional_property on Content for structured access - Move temperature, max_tokens, stop into options dict (not top-level kwargs) - Only set temperature when provided (not all models support it) - Add tests for generation params in options and temperature omission Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix MCP sampling callback and structured content error handling (#4625) - Guard max_tokens like temperature: only set when not None, so options can properly evaluate to None when all params are absent - Wrap json.dumps of structuredContent in try/except to fall back to str() for non-serializable values instead of propagating TypeError - Extract test_connect_sampling_capabilities_with_client into its own test function so pytest can discover it independently - Add test for max_tokens=None omission from options - Update structured content non-serializable test to expect fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #4625: review comment fixes * Fix MCP and Azure validation regressions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/core/agent_framework/_mcp.py | 41 +- python/packages/core/tests/core/test_mcp.py | 366 +++++++++++++++++- .../openai/agent_framework_openai/_shared.py | 4 +- .../packages/openai/tests/openai/conftest.py | 2 + ...est_openai_chat_completion_client_azure.py | 3 +- 5 files changed, 409 insertions(+), 7 deletions(-) diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index c7db6177da..267e176ee8 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -18,7 +18,11 @@ from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast from opentelemetry import propagate from ._tools import FunctionTool -from ._types import Content, Message +from ._types import ( + ChatOptions, + Content, + Message, +) from .exceptions import ToolException, ToolExecutionException if sys.version_info >= (3, 11): @@ -640,6 +644,7 @@ class MCPTool: raise ToolException(error_msg, inner_exception=ex) from ex try: try: + from mcp import types from mcp.client.session import ClientSession as runtime_client_session except ModuleNotFoundError as ex: await self._safe_close_exit_stack() @@ -647,6 +652,12 @@ class MCPTool: "MCP support requires `mcp`. Please install `mcp`.", inner_exception=ex, ) from ex + + sampling_capabilities = None + if self.client is not None: + sampling_capabilities = types.SamplingCapability( + tools=types.SamplingToolsCapability(), + ) session = await self._exit_stack.enter_async_context( runtime_client_session( read_stream=transport[0], @@ -657,6 +668,7 @@ class MCPTool: message_handler=self.message_handler, logging_callback=self.logging_callback, sampling_callback=self.sampling_callback, + sampling_capabilities=sampling_capabilities, ) ) except Exception as ex: @@ -733,14 +745,35 @@ class MCPTool: messages: list[Message] = [] for msg in params.messages: messages.append(self._parse_message_from_mcp(msg)) + + options: ChatOptions[None] = {} + if params.systemPrompt is not None: + options["instructions"] = params.systemPrompt + if params.tools is not None: + options["tools"] = [ + FunctionTool( + name=tool.name, + description=tool.description or "", + input_model=tool.inputSchema, + ) + for tool in params.tools + ] + if params.toolChoice is not None and params.toolChoice.mode is not None: + options["tool_choice"] = params.toolChoice.mode + + if params.temperature is not None: + options["temperature"] = params.temperature + options["max_tokens"] = params.maxTokens + if params.stopSequences is not None: + options["stop"] = params.stopSequences + try: response = await self.client.get_response( messages, - temperature=params.temperature, - max_tokens=params.maxTokens, - stop=params.stopSequences, + options=options or None, ) except Exception as ex: + logger.debug("Sampling callback error: %s", ex, exc_info=True) return types.ErrorData( code=types.INTERNAL_ERROR, message=f"Failed to get chat message content: {ex}", diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index 3c340a13b8..eb233eea99 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -1696,12 +1696,15 @@ async def test_mcp_tool_sampling_callback_chat_client_exception(): params.temperature = None params.maxTokens = None params.stopSequences = None + params.systemPrompt = None + params.tools = None + params.toolChoice = None result = await tool.sampling_callback(Mock(), params) assert isinstance(result, types.ErrorData) assert result.code == types.INTERNAL_ERROR - assert "Failed to get chat message content: Chat client error" in result.message + assert "Failed to get chat message content" in result.message async def test_mcp_tool_sampling_callback_no_valid_content(): @@ -1739,6 +1742,9 @@ async def test_mcp_tool_sampling_callback_no_valid_content(): params.temperature = None params.maxTokens = None params.stopSequences = None + params.systemPrompt = None + params.tools = None + params.toolChoice = None result = await tool.sampling_callback(Mock(), params) @@ -1757,6 +1763,9 @@ async def test_mcp_tool_sampling_callback_no_response_and_successful_message_cre params.temperature = None params.maxTokens = None params.stopSequences = None + params.systemPrompt = None + params.tools = None + params.toolChoice = None tool.client.get_response.return_value = None no_response = await tool.sampling_callback(Mock(), params) @@ -1787,6 +1796,361 @@ async def test_mcp_tool_logging_callback_logs_at_requested_level() -> None: mock_log.assert_called_once_with(logging.WARNING, "be careful") +async def test_mcp_tool_sampling_callback_forwards_system_prompt(): + """Test sampling callback passes systemPrompt as instructions in options.""" + from agent_framework import Message + + tool = MCPStdioTool(name="test_tool", command="python") + + mock_chat_client = AsyncMock() + mock_response = Mock() + mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] + mock_response.model_id = "test-model" + mock_chat_client.get_response.return_value = mock_response + + tool.client = mock_chat_client + + params = Mock() + mock_message = Mock() + mock_message.role = "user" + mock_message.content = Mock() + mock_message.content.text = "Test question" + params.messages = [mock_message] + params.temperature = None + params.maxTokens = None + params.stopSequences = None + params.systemPrompt = "You are a helpful assistant" + params.tools = None + params.toolChoice = None + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.CreateMessageResult) + call_kwargs = mock_chat_client.get_response.call_args + options = call_kwargs.kwargs.get("options") or {} + assert options.get("instructions") == "You are a helpful assistant" + + +async def test_mcp_tool_sampling_callback_forwards_tools(): + """Test sampling callback converts MCP tools to FunctionTools and passes them in options.""" + from agent_framework import FunctionTool, Message + + tool = MCPStdioTool(name="test_tool", command="python") + + mock_chat_client = AsyncMock() + mock_response = Mock() + mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] + mock_response.model_id = "test-model" + mock_chat_client.get_response.return_value = mock_response + + tool.client = mock_chat_client + + mcp_tool = types.Tool( + name="get_weather", + description="Get weather", + inputSchema={"type": "object", "properties": {"city": {"type": "string"}}}, + ) + + params = Mock() + mock_message = Mock() + mock_message.role = "user" + mock_message.content = Mock() + mock_message.content.text = "Test question" + params.messages = [mock_message] + params.temperature = None + params.maxTokens = None + params.stopSequences = None + params.systemPrompt = None + params.tools = [mcp_tool] + params.toolChoice = None + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.CreateMessageResult) + call_kwargs = mock_chat_client.get_response.call_args + options = call_kwargs.kwargs.get("options") or {} + tools = options.get("tools") + assert tools is not None + assert len(tools) == 1 + assert isinstance(tools[0], FunctionTool) + assert tools[0].name == "get_weather" + assert tools[0].description == "Get weather" + + +async def test_mcp_tool_sampling_callback_forwards_tool_choice(): + """Test sampling callback passes toolChoice mode in options.""" + from agent_framework import Message + + tool = MCPStdioTool(name="test_tool", command="python") + + mock_chat_client = AsyncMock() + mock_response = Mock() + mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] + mock_response.model_id = "test-model" + mock_chat_client.get_response.return_value = mock_response + + tool.client = mock_chat_client + + params = Mock() + mock_message = Mock() + mock_message.role = "user" + mock_message.content = Mock() + mock_message.content.text = "Test question" + params.messages = [mock_message] + params.temperature = None + params.maxTokens = None + params.stopSequences = None + params.systemPrompt = None + params.tools = None + params.toolChoice = types.ToolChoice(mode="required") + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.CreateMessageResult) + call_kwargs = mock_chat_client.get_response.call_args + options = call_kwargs.kwargs.get("options") or {} + assert options.get("tool_choice") == "required" + + +async def test_mcp_tool_sampling_callback_forwards_empty_system_prompt(): + """Test sampling callback forwards empty string systemPrompt as instructions.""" + from agent_framework import Message + + tool = MCPStdioTool(name="test_tool", command="python") + + mock_chat_client = AsyncMock() + mock_response = Mock() + mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] + mock_response.model_id = "test-model" + mock_chat_client.get_response.return_value = mock_response + + tool.client = mock_chat_client + + params = Mock() + mock_message = Mock() + mock_message.role = "user" + mock_message.content = Mock() + mock_message.content.text = "Test question" + params.messages = [mock_message] + params.temperature = None + params.maxTokens = None + params.stopSequences = None + params.systemPrompt = "" + params.tools = None + params.toolChoice = None + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.CreateMessageResult) + call_kwargs = mock_chat_client.get_response.call_args + options = call_kwargs.kwargs.get("options") or {} + assert options.get("instructions") == "" + + +async def test_mcp_tool_sampling_callback_forwards_empty_tools_list(): + """Test sampling callback forwards empty tools list in options.""" + from agent_framework import Message + + tool = MCPStdioTool(name="test_tool", command="python") + + mock_chat_client = AsyncMock() + mock_response = Mock() + mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] + mock_response.model_id = "test-model" + mock_chat_client.get_response.return_value = mock_response + + tool.client = mock_chat_client + + params = Mock() + mock_message = Mock() + mock_message.role = "user" + mock_message.content = Mock() + mock_message.content.text = "Test question" + params.messages = [mock_message] + params.temperature = None + params.maxTokens = None + params.stopSequences = None + params.systemPrompt = None + params.tools = [] + params.toolChoice = None + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.CreateMessageResult) + call_kwargs = mock_chat_client.get_response.call_args + options = call_kwargs.kwargs.get("options") or {} + assert options.get("tools") == [] + + +async def test_mcp_tool_sampling_callback_forwards_generation_params_in_options(): + """Test sampling callback passes temperature, max_tokens, and stop in options.""" + from agent_framework import Message + + tool = MCPStdioTool(name="test_tool", command="python") + + mock_chat_client = AsyncMock() + mock_response = Mock() + mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] + mock_response.model_id = "test-model" + mock_chat_client.get_response.return_value = mock_response + + tool.client = mock_chat_client + + params = Mock() + mock_message = Mock() + mock_message.role = "user" + mock_message.content = Mock() + mock_message.content.text = "Test question" + params.messages = [mock_message] + params.temperature = 0.7 + params.maxTokens = 256 + params.stopSequences = ["STOP"] + params.systemPrompt = None + params.tools = None + params.toolChoice = None + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.CreateMessageResult) + call_kwargs = mock_chat_client.get_response.call_args + options = call_kwargs.kwargs.get("options") or {} + assert options.get("temperature") == 0.7 + assert options.get("max_tokens") == 256 + assert options.get("stop") == ["STOP"] + # These should not be passed as top-level kwargs + assert "temperature" not in call_kwargs.kwargs + assert "max_tokens" not in call_kwargs.kwargs + assert "stop" not in call_kwargs.kwargs + + +async def test_mcp_tool_sampling_callback_omits_temperature_when_none(): + """Test sampling callback does not set temperature in options when it is None.""" + from agent_framework import Message + + tool = MCPStdioTool(name="test_tool", command="python") + + mock_chat_client = AsyncMock() + mock_response = Mock() + mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] + mock_response.model_id = "test-model" + mock_chat_client.get_response.return_value = mock_response + + tool.client = mock_chat_client + + params = Mock() + mock_message = Mock() + mock_message.role = "user" + mock_message.content = Mock() + mock_message.content.text = "Test question" + params.messages = [mock_message] + params.temperature = None + params.maxTokens = 100 + params.stopSequences = None + params.systemPrompt = None + params.tools = None + params.toolChoice = None + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.CreateMessageResult) + call_kwargs = mock_chat_client.get_response.call_args + options = call_kwargs.kwargs.get("options") or {} + assert "temperature" not in options + assert options.get("max_tokens") == 100 + assert "stop" not in options + + +async def test_mcp_tool_sampling_callback_always_passes_max_tokens(): + """Test sampling callback always sets max_tokens in options since maxTokens is a required int field.""" + from agent_framework import Message + + tool = MCPStdioTool(name="test_tool", command="python") + + mock_chat_client = AsyncMock() + mock_response = Mock() + mock_response.messages = [Message(role="assistant", contents=[Content.from_text("response")])] + mock_response.model_id = "test-model" + mock_chat_client.get_response.return_value = mock_response + + tool.client = mock_chat_client + + params = Mock() + mock_message = Mock() + mock_message.role = "user" + mock_message.content = Mock() + mock_message.content.text = "Test question" + params.messages = [mock_message] + params.temperature = None + params.maxTokens = 200 + params.stopSequences = None + params.systemPrompt = None + params.tools = None + params.toolChoice = None + + result = await tool.sampling_callback(Mock(), params) + + assert isinstance(result, types.CreateMessageResult) + call_kwargs = mock_chat_client.get_response.call_args + options = call_kwargs.kwargs.get("options") or {} + assert options["max_tokens"] == 200 + + +async def test_connect_sampling_capabilities_with_client(): + """Test connect() passes sampling_capabilities to ClientSession when client is set.""" + tool = MCPStdioTool(name="test", command="test-command", load_tools=False, load_prompts=False) + tool.client = Mock() + + mock_transport = (Mock(), Mock()) + mock_context_manager = Mock() + mock_context_manager.__aenter__ = AsyncMock(return_value=mock_transport) + mock_context_manager.__aexit__ = AsyncMock(return_value=None) + tool.get_mcp_client = Mock(return_value=mock_context_manager) + + with patch("mcp.client.session.ClientSession") as mock_session_class: + mock_session = AsyncMock() + mock_session._request_id = 1 + + session_cm = AsyncMock() + session_cm.__aenter__ = AsyncMock(return_value=mock_session) + session_cm.__aexit__ = AsyncMock(return_value=None) + mock_session_class.return_value = session_cm + + await tool.connect() + + call_kwargs = mock_session_class.call_args.kwargs + sampling_caps = call_kwargs.get("sampling_capabilities") + assert sampling_caps is not None + assert isinstance(sampling_caps, types.SamplingCapability) + assert sampling_caps.tools is not None + assert isinstance(sampling_caps.tools, types.SamplingToolsCapability) + + +async def test_connect_no_sampling_capabilities_without_client(): + """Test connect() does not pass sampling_capabilities when no client is set.""" + tool = MCPStdioTool(name="test", command="test-command", load_tools=False, load_prompts=False) + # No client set + + mock_transport = (Mock(), Mock()) + mock_context_manager = Mock() + mock_context_manager.__aenter__ = AsyncMock(return_value=mock_transport) + mock_context_manager.__aexit__ = AsyncMock(return_value=None) + tool.get_mcp_client = Mock(return_value=mock_context_manager) + + with patch("mcp.client.session.ClientSession") as mock_session_class: + mock_session = AsyncMock() + mock_session._request_id = 1 + + session_cm = AsyncMock() + session_cm.__aenter__ = AsyncMock(return_value=mock_session) + session_cm.__aexit__ = AsyncMock(return_value=None) + mock_session_class.return_value = session_cm + + await tool.connect() + + call_kwargs = mock_session_class.call_args.kwargs + assert call_kwargs.get("sampling_capabilities") is None + + # Test error handling in connect() method diff --git a/python/packages/openai/agent_framework_openai/_shared.py b/python/packages/openai/agent_framework_openai/_shared.py index 900592da17..c3c280d950 100644 --- a/python/packages/openai/agent_framework_openai/_shared.py +++ b/python/packages/openai/agent_framework_openai/_shared.py @@ -216,7 +216,9 @@ def load_openai_service_settings( openai_settings["model"] = resolved_model break - if not openai_settings.get("api_version"): + if api_version is not None: + openai_settings["api_version"] = api_version + else: resolved_api_version = _get_setting_from_alias( "AZURE_OPENAI_API_VERSION", dotenv_values_by_name=dotenv_values_by_name, diff --git a/python/packages/openai/tests/openai/conftest.py b/python/packages/openai/tests/openai/conftest.py index d2a89a6742..1ef52baf81 100644 --- a/python/packages/openai/tests/openai/conftest.py +++ b/python/packages/openai/tests/openai/conftest.py @@ -48,6 +48,7 @@ def openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # "OPENAI_AUDIO_TO_TEXT_MODEL_ID", "OPENAI_TEXT_TO_AUDIO_MODEL_ID", "OPENAI_REALTIME_MODEL_ID", + "OPENAI_API_VERSION", "OPENAI_BASE_URL", "AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_BASE_URL", @@ -101,6 +102,7 @@ def azure_openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dic "OPENAI_AUDIO_TO_TEXT_MODEL_ID", "OPENAI_TEXT_TO_AUDIO_MODEL_ID", "OPENAI_REALTIME_MODEL_ID", + "OPENAI_API_VERSION", "OPENAI_BASE_URL", "AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_BASE_URL", diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client_azure.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client_azure.py index eb27312684..148fcb68b9 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client_azure.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client_azure.py @@ -79,7 +79,8 @@ def test_init_auto_detects_azure_env(azure_openai_unit_test_env: dict[str, str]) @pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_API_VERSION"]], indirect=True) -def test_init_uses_default_azure_api_version(azure_openai_unit_test_env: dict[str, str]) -> None: +def test_init_uses_default_azure_api_version(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: + monkeypatch.setenv("OPENAI_API_VERSION", "preview") client = _create_azure_chat_completion_client() assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] From bfda595e5651970655cb9597085608984b28bedb Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:10:33 +0000 Subject: [PATCH 07/22] Add ADR to decide consistency of Chat History Persistence (#4816) * Add ADR to decide consitency of Chat History Persistence * Add example * Update ADR with review results * Remove unecessary clarification * Rename ADR to no 22 --- ...22-chat-history-persistence-consistency.md | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 docs/decisions/0022-chat-history-persistence-consistency.md diff --git a/docs/decisions/0022-chat-history-persistence-consistency.md b/docs/decisions/0022-chat-history-persistence-consistency.md new file mode 100644 index 0000000000..2c760e6614 --- /dev/null +++ b/docs/decisions/0022-chat-history-persistence-consistency.md @@ -0,0 +1,116 @@ +--- +status: accepted +contact: westey-m +date: 2026-03-23 +deciders: sergeymenshykh, markwallace, rbarreto, dmytrostruk, westey-m, eavanvalkenburg, stephentoub +consulted: +informed: +--- + +# Chat History Persistence Consistency + +## Context and Problem Statement + +When using `ChatClientAgent` with tools, the `FunctionInvokingChatClient` (FIC) loops multiple times — service call → tool execution → service call → … — before producing a final response. There are two points of discrepancy between how chat history is stored by the framework's `ChatHistoryProvider` and how the underlying AI service stores chat history (e.g., OpenAI Responses with `store=true`): + +1. **Persistence timing**: The AI service persists messages after *each* service call within the FIC loop. The `ChatHistoryProvider` currently persists messages only once, at the *end* of the full agent run (after all FIC loop iterations complete). + +2. **Trailing `FunctionResultContent` storage**: When tool calling is terminated mid-loop (e.g., via `FunctionInvokingChatClient` termination filters), the final response from the agent may contain `FunctionResultContent` that was never sent to a subsequent service call. The AI service never stores this trailing `FunctionResultContent`, but the `ChatHistoryProvider` currently stores all response content, including the trailing `FunctionResultContent`. + +These discrepancies mean that a `ChatHistoryProvider`-managed conversation and a service-managed conversation can diverge in content and structure, even when processing the same interactions. + +### Practical Impact: Resuming After Tool-Call Termination + +Today, users of `AIAgent` get different behaviors depending on whether chat history is stored service-side or in a `ChatHistoryProvider`. This creates concrete challenges — for example, when the function call loop is terminated and the user wants to resume the conversation in a subsequent run. With service-stored history, the trailing `FunctionResultContent` is never persisted, so the last stored message is the `FunctionCallContent` from the service. With `ChatHistoryProvider`-stored history, the trailing `FunctionResultContent` *is* persisted. The user cannot know whether the last `FunctionResultContent` is in the chat history or not without inspecting the storage mechanism, making it difficult to write resumption logic that works correctly regardless of the storage backend. + +### Relationship Between the Two Discrepancies + +The persistence timing and `FunctionResultContent` trimming behaviors are interrelated: + +- **Per-service-call persistence**: When messages are persisted after each individual service call, trailing `FunctionResultContent` trimming is unnecessary. If tool calling is terminated, the `FunctionResultContent` from the terminated call was never sent to a subsequent service call, so it is never persisted. The per-service-call approach naturally matches the service's behavior. + +- **Per-run persistence**: When messages are batched and persisted at the end of the full run, trailing `FunctionResultContent` trimming becomes necessary to match the service's behavior. Without trimming, the stored history contains `FunctionResultContent` that the service would never have stored. + +This means the trimming feature (introduced in [PR #4792](https://github.com/microsoft/agent-framework/pull/4792)) is primarily needed as a complement to per-run persistence. The `PersistChatHistoryAtEndOfRun` setting (introduced in [PR #4762](https://github.com/microsoft/agent-framework/pull/4762)) inverts the default so that per-service-call persistence is the standard behavior, and per-run persistence is opt-in. + +## Decision Drivers + +- **A. Consistency**: The default behavior of `ChatHistoryProvider` should produce stored history that closely matches what the underlying AI service would store, minimizing surprise when switching between framework-managed and service-managed chat history. +- **B. Atomicity**: A run that fails mid-way through a multi-step tool-calling loop should not leave chat history in a partially-updated state, unless the user explicitly opts into that behavior. +- **C. Recoverability**: For long-running tool-calling loops, it should be possible to recover intermediate progress if the process is interrupted, rather than losing all work from the current run. +- **D. Simplicity**: The default behavior should be easy to understand and predict for most users, without requiring knowledge of the FIC loop internals. +- **E. Flexibility**: Regardless of the chosen default, users should be able to opt into the alternative behavior. + +## Considered Options + +- Option 1: Default to per-run persistence with `FunctionResultContent` trimming (opt-in to per-service-call) +- Option 2: Default to per-service-call persistence (opt-in to per-run) + +## Pros and Cons of the Options + +### Option 1: Default to per-run persistence with `FunctionResultContent` trimming + +Keep the current default behavior of persisting chat history only at the end of the full agent run. Add `FunctionResultContent` trimming as the default to improve consistency with service storage. Provide an opt-in setting for users who want per-service-call persistence. + +Settings: +- `PersistChatHistoryAtEndOfRun` = `true` + +- Good, because runs are atomic — chat history is only updated when the full run succeeds, satisfying driver B. +- Good, because the mental model is simple: one run = one history update, satisfying driver D. +- Good, because trimming trailing `FunctionResultContent` improves consistency with service storage, partially satisfying driver A. +- Good, because users can opt in to per-service-call persistence for checkpointing/recovery scenarios, satisfying drivers C and E. +- Bad, because the default persistence timing still differs from the service's behavior (per-run vs. per-service-call), only partially satisfying driver A. +- Bad, because if the process crashes mid-loop, all intermediate progress from the current run is lost, not satisfying driver C by default. + +### Option 2: Default to per-service-call persistence + +Change the default to persist chat history after each individual service call within the FIC loop, matching the AI service's behavior. Trailing `FunctionResultContent` trimming is unnecessary with this approach (it is naturally handled). Provide an opt-in setting for users who want per-run atomicity with trimming. + +Settings: +- `PersistChatHistoryAtEndOfRun` = `false` (default) + +- Good, because the stored history matches the service's behavior by default for both timing and content, fully satisfying driver A. +- Good, because intermediate progress is preserved if the process is interrupted, satisfying driver C. +- Good, because no separate `FunctionResultContent` trimming logic is needed, reducing complexity. +- Bad, because chat history may be left in an incomplete state if the run fails mid-loop (e.g., `FunctionCallContent` stored without corresponding `FunctionResultContent`), not satisfying driver B. A subsequent run cannot proceed without manually providing the missing `FunctionResultContent`. +- Bad, because the mental model is more complex: a single run may produce multiple history updates, partially failing driver D. +- Neutral, because users can opt out to per-run persistence if they prefer atomicity, satisfying driver E. + +## Decision Outcome + +Chosen option: **Option 2 — Default to per-service-call persistence**, because it fully satisfies the consistency driver (A), naturally handles `FunctionResultContent` trimming without additional logic, and provides better recoverability for long-running tool-calling loops. Per-run persistence remains available via the `PersistChatHistoryAtEndOfRun` setting for users who prefer atomic run semantics. + +### Configuration Matrix + +The behavior depends on the combination of `UseProvidedChatClientAsIs` and `PersistChatHistoryAtEndOfRun`: + +| `UseProvidedChatClientAsIs` | `PersistChatHistoryAtEndOfRun` | Behavior | +|---|---|---| +| `false` (default) | `false` (default) | **Per-service-call persistence.** A `ChatHistoryPersistingChatClient` middleware is automatically injected into the chat client pipeline between `FunctionInvokingChatClient` and the leaf `IChatClient`. Messages are persisted after each service call. | +| `true` | `false` | **User responsibility.** No middleware is injected because the user has provided a custom chat client stack. The user is responsible for ensuring correct persistence behavior (e.g., by including their own persisting middleware). | +| `false` | `true` | **Per-run persistence with marking.** A `ChatHistoryPersistingChatClient` middleware is injected, but configured to *mark* messages with metadata rather than store them immediately. At the end of the run, marked messages are stored. Trailing `FunctionResultContent` is trimmed. | +| `true` | `true` | **Per-run persistence with warning.** The system checks whether the custom chat client stack includes a `ChatHistoryPersistingChatClient`. If not, a warning is emitted (particularly relevant for workflow handoff scenarios where trimming cannot be guaranteed). If no `ChatHistoryPersistingChatClient` is preset, all messages are stored at the end of the run, otherwise marked messages are stored. | + +### Consequences + +- Good, because the stored history matches the service's behavior by default for both timing and content, fully satisfying consistency (driver A). +- Good, because intermediate progress is preserved if the process is interrupted, satisfying recoverability (driver C). +- Good, because no separate `FunctionResultContent` trimming logic is needed in the default path, reducing complexity. +- Good, because marking persisted messages with metadata enables deduplication and aids debugging. +- Good, because warnings for custom chat client configurations without the persisting middleware help prevent silent failures in workflow handoff scenarios. +- Bad, because chat history may be left in an incomplete state if the run fails mid-loop (e.g., `FunctionCallContent` stored without corresponding `FunctionResultContent`), requiring manual recovery in rare cases. +- Bad, because the mental model is more complex for the default path: a single run may produce multiple history updates. +- Neutral, because users who prefer atomic run semantics can opt in to per-run persistence via `PersistChatHistoryAtEndOfRun = true`. +- Neutral, because increased write frequency from per-service-call persistence may impact performance for some storage backends; this can be mitigated with a caching decorator. + +### Implementation Notes + +#### Conversation ID Consistency + +The `ChatHistoryPersistingChatClient` middleware must also update the session's `ConversationId` consistently for both response-based and conversation-based service interactions, ensuring the session always reflects the latest service-provided identifier. + +## More Information + +- [PR #4762: Persist messages during function call loop](https://github.com/microsoft/agent-framework/pull/4762) — introduces `PersistChatHistoryAfterEachServiceCall` option and `ChatHistoryPersistingChatClient` decorator +- [PR #4792: Trim final FRC to match service storage](https://github.com/microsoft/agent-framework/pull/4792) — introduces `StoreFinalFunctionResultContent` option and `FilterFinalFunctionResultContent` logic +- [Issue #2889](https://github.com/microsoft/agent-framework/issues/2889) — original issue tracking chat history persistence during function call loops From 6626565f7a6041eb8e81c9ded17181f6182a495a Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:19:09 +0000 Subject: [PATCH 08/22] ADR to support a multi-source architecture for agent skills (#4787) * add adr suggesting a new design to support a multi-source architecture for agent skills * add deciders * move the adr to the decisions folder * remove unnecessary section * describe adding a custom skill source * update * address comments * add constructor overloads to inline skill resource and script * consider ai-function as an alternative for skill script and skill resource model classes * update decision outcome section and sync adr with latest changes in the code --- docs/decisions/0021-agent-skills-design.md | 960 +++++++++++++++++++++ 1 file changed, 960 insertions(+) create mode 100644 docs/decisions/0021-agent-skills-design.md diff --git a/docs/decisions/0021-agent-skills-design.md b/docs/decisions/0021-agent-skills-design.md new file mode 100644 index 0000000000..d63f38c734 --- /dev/null +++ b/docs/decisions/0021-agent-skills-design.md @@ -0,0 +1,960 @@ +status: proposed +date: 2026-03-23 +contact: sergeymenshykh +deciders: rbarreto, westey-m, eavanvalkenburg +--- + +# Agent Skills: Multi-Source Architecture + +## Context and Problem Statement + +The Agent Framework needs a skills system that lets agents discover and use domain-specific knowledge, reference documents, and executable scripts. Skills can originate from different sources — filesystem directories (SKILL.md files), inline C# code, or reusable class libraries — and the framework must support all three uniformly while allowing extensibility, composition, and filtering. + +## Decision Drivers + +- Skills must be definable from multiple sources: filesystem, inline code, reusable classes, etc +- Common abstractions are needed so the provider and builder work uniformly regardless of skill origin +- File-based scripts must support user-defined executors, enabling custom runtimes and languages; code/class-based scripts execute in-process as C# delegates +- Skills must be filterable so consumers can include or exclude specific skills based on defined criteria +- Multiple skill sources must be composable into a single provider +- It must be possible to add custom skill sources (e.g., databases, REST APIs, package registries) by implementing a common abstraction + +## Architecture + +### Model-Facing Tools + +Skills are presented to the model as up to three tools that progressively disclose skill content. The system prompt lists available skill names and descriptions; the model then calls these tools on demand: + +- **`load_skill(skillName)`** — returns the full skill body (instructions, listed resources, listed scripts) +- **`read_skill_resource(skillName, resourceName)`** — reads a supplementary resource (file-based or code-defined) associated with a skill +- **`run_skill_script(skillName, scriptName, arguments?)`** — executes a script associated with a skill; only registered when at least one skill contains scripts + +Each tool delegates to the corresponding method on the resolved `AgentSkill` — calling `Resource.ReadAsync()` or `Script.RunAsync()` respectively. + +If skills have no scripts defined, the `run_skill_script` tool is **not advertised** to the model and instructions related to script execution are **not included** in the default skills instructions. + +### Abstract Base Types + +The architecture defines four abstract base types that all skill variants implement: + +```csharp +public abstract class AgentSkill +{ + public abstract AgentSkillFrontmatter Frontmatter { get; } + public abstract string Content { get; } + public abstract IReadOnlyList? Resources { get; } + public abstract IReadOnlyList? Scripts { get; } +} + +public abstract class AgentSkillResource +{ + public string Name { get; } + public string? Description { get; } + public abstract Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default); +} + +public abstract class AgentSkillScript +{ + public string Name { get; } + public string? Description { get; } + public abstract Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default); +} + +public abstract class AgentSkillsSource +{ + public abstract Task> GetSkillsAsync(CancellationToken cancellationToken = default); +} +``` + +Skill metadata is captured via `AgentSkillFrontmatter`: + +```csharp +public sealed class AgentSkillFrontmatter +{ + public AgentSkillFrontmatter(string name, string description) { ... } + + public string Name { get; } + public string Description { get; } + public string? License { get; set; } + public string? Compatibility { get; set; } + public string? AllowedTools { get; set; } + public AdditionalPropertiesDictionary? Metadata { get; set; } +} +``` + +The type hierarchy at a glance: + +``` +AgentSkill (abstract) AgentSkillsSource (abstract) +├── AgentFileSkill ├── AgentFileSkillsSource (public) +└── [Programmatic] ├── AgentInMemorySkillsSource (public) + ├── AgentInlineSkill ├── AggregatingAgentSkillsSource (public) + └── AgentClassSkill (abstract) └── DelegatingAgentSkillsSource (abstract, public) + ├── FilteringAgentSkillsSource (public) +AgentSkillResource (abstract) ├── CachingAgentSkillsSource (public) +├── AgentFileSkillResource └── DeduplicatingAgentSkillsSource (public) +└── AgentInlineSkillResource + AgentSkillScript (abstract) + ├── AgentFileSkillScript + └── AgentInlineSkillScript +``` + +There are two top-level categories of skills: + +1. **File-Based Skills** — discovered from `SKILL.md` files on the filesystem. Resources and scripts are files in subdirectories. +2. **Programmatic Skills** — defined in C# code. These are further divided into: + - **Inline Skills** — built at runtime via the `AgentInlineSkill` class and its fluent API. Ideal for quick, agent-specific skill definitions. + - **Class-Based Skills** — defined as reusable C# classes that subclass `AgentClassSkill`. Ideal for packaging skills as shared libraries or NuGet packages. + +Both programmatic skill types use `AgentInlineSkillResource` and `AgentInlineSkillScript` for their resources and scripts. They are typically served by `AgentInMemorySkillsSource`, which accepts any `AgentSkill` and is not limited to programmatic skills. + +### File-Based Skills + +File-based skills are authored as `SKILL.md` files on disk. Resources and scripts are discovered from corresponding subfolders within the skill directory. + +**`AgentFileSkill`** — A filesystem-based skill discovered from a directory containing a `SKILL.md` file. Parsed from YAML frontmatter; content is the raw markdown body. Resources and scripts are discovered from files in corresponding subfolders: + +```csharp +public sealed class AgentFileSkill : AgentSkill +{ + internal AgentFileSkill( + AgentSkillFrontmatter frontmatter, string content, string path, + IReadOnlyList? resources = null, + IReadOnlyList? scripts = null) { ... } +} +``` + +**`AgentFileSkillResource`** — A file-based skill resource. Reads content from a file on disk relative to the skill directory: + +```csharp +internal sealed class AgentFileSkillResource : AgentSkillResource +{ + public AgentFileSkillResource(string name, string fullPath) { ... } + + public string FullPath { get; } + + public override Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) + { + return File.ReadAllTextAsync(FullPath, Encoding.UTF8, cancellationToken); + } +} +``` + +**`AgentFileSkillScript`** — A file-based skill script that represents a script file on disk. Delegates execution to an external `AgentFileSkillScriptRunner` callback (e.g., runs Python/shell via `Process.Start`). Throws `NotSupportedException` if no executor is configured: + +```csharp +public delegate Task AgentFileSkillScriptRunner( + AgentFileSkill skill, AgentFileSkillScript script, + AIFunctionArguments arguments, CancellationToken cancellationToken); + +public sealed class AgentFileSkillScript : AgentSkillScript +{ + private readonly AgentFileSkillScriptRunner _executor; + + internal AgentFileSkillScript(string name, string fullPath, AgentFileSkillScriptRunner executor) + : base(name) { ... } + + public override async Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, ...) + { + + return await _executor(fileSkill, this, arguments, cancellationToken); + } +} +``` + +The executor can be provided at the **provider level** via `AgentSkillsProviderBuilder.UseFileScriptRunner(executor)` and optionally overridden for a **particular file skill** or for a **set of skills** at the file skill source level, giving fine-grained control over how different scripts are executed. + +**`AgentFileSkillsSource`** — A skill source that discovers skills from filesystem directories containing `SKILL.md` files. Recursively scans directories (max 2 levels), validates frontmatter, and enforces path traversal and symlink security checks: + +```csharp +public sealed partial class AgentFileSkillsSource : AgentSkillsSource +{ + public AgentFileSkillsSource( + IEnumerable skillPaths, + AgentFileSkillScriptRunner scriptRunner, + AgentFileSkillsSourceOptions? options = null, + ILoggerFactory? loggerFactory = null) { ... } +} +``` + +**`AgentFileSkillsSourceOptions`** — Configuration options for `AgentFileSkillsSource`. Allows customizing the allowed file extensions for resources and scripts without adding constructor parameters: + +```csharp +public sealed class AgentFileSkillsSourceOptions +{ + public IEnumerable? AllowedResourceExtensions { get; set; } + public IEnumerable? AllowedScriptExtensions { get; set; } +} +``` + +**Example** — A file-based skill on disk and how it is added to a source: + +``` +skills/ +└── unit-converter/ + ├── SKILL.md # frontmatter + instructions + ├── resources/ + │ └── conversion-table.csv # discovered as a resource + └── scripts/ + └── convert.py # discovered as a script +``` + +```csharp +var source = new AgentFileSkillsSource(skillPaths: ["./skills"], scriptRunner: SubprocessScriptRunner.RunAsync); + +var provider = new AgentSkillsProvider(source); + +AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions +{ + AIContextProviders = [provider], +}); +``` + +### Programmatic Skills + +Programmatic skills are defined in C# code rather than discovered from the filesystem. There are two kinds: **inline** and **class-based**. Both use `AgentInlineSkillResource` and `AgentInlineSkillScript` for resources and scripts, and are held by a single `AgentInMemorySkillsSource`. + +**`AgentInMemorySkillsSource`** — A general-purpose skill source that holds any `AgentSkill` instances in memory. Although commonly used for programmatic skills (`AgentInlineSkill` and `AgentClassSkill`), it accepts any `AgentSkill` subclass and is not restricted to code-defined skills: + +```csharp +public sealed class AgentInMemorySkillsSource : AgentSkillsSource +{ + public AgentInMemorySkillsSource( + IEnumerable skills, + ILoggerFactory? loggerFactory = null) { ... } +} +``` + +#### Inline Skills + +Inline skills are built at runtime via the `AgentInlineSkill` class and its fluent API. They are ideal for quick, agent-specific skill definitions where a full class hierarchy would be overkill. + +**`AgentInlineSkill`** — A skill defined entirely in code. Resources can be static values or functions; scripts are always functions. Constructed with name, description, and instructions, then extended with resources and scripts: + +```csharp +public sealed class AgentInlineSkill : AgentSkill +{ + public AgentInlineSkill(string name, string description, string instructions, string? license = null, string? compatibility = null, ...) { ... } + public AgentInlineSkill(AgentSkillFrontmatter frontmatter, string instructions) { ... } + + public AgentInlineSkill AddResource(object value, string name, string? description = null); + public AgentInlineSkill AddResource(Delegate handler, string name, string? description = null); + public AgentInlineSkill AddScript(Delegate handler, string name, string? description = null); +} +``` + +**`AgentInlineSkillResource`** — A skill resource that wraps a static value: + +```csharp +public sealed class AgentInlineSkillResource : AgentSkillResource +{ + public AgentInlineSkillResource(object value, string name, string? description = null) + : base(name, description) + { + _value = value; + } + + public override Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(_value); + } +} +``` + +**`AgentInlineSkillResource`** — A skill resource backed by a delegate. The delegate is invoked via an `AIFunction` each time `ReadAsync` is called, producing a dynamic (computed) value: + +```csharp +public sealed class AgentInlineSkillResource : AgentSkillResource +{ + public AgentInlineSkillResource(Delegate handler, string name, string? description = null) + : base(name, description) + { + _function = AIFunctionFactory.Create(handler, name: name); + } + + public override async Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) + { + return await _function.InvokeAsync(new AIFunctionArguments() { Services = serviceProvider }, cancellationToken); + } +} +``` + +**`AgentInlineSkillScript`** — A skill script backed by a delegate via an `AIFunction`: + +```csharp +public sealed class AgentInlineSkillScript : AgentSkillScript +{ + private readonly AIFunction _function; + + public AgentInlineSkillScript(Delegate handler, string name, string? description = null) + : base(name, description) + { + _function = AIFunctionFactory.Create(handler, name: name); + } + + public JsonElement? ParametersSchema => _function.JsonSchema; + + public override async Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, ...) + { + return await _function.InvokeAsync(arguments, cancellationToken); + } +} +``` + +**Example** — Creating an inline skill with a resource and script, then adding it to a source: + +```csharp +var skill = new AgentInlineSkill( + name: "unit-converter", + description: "Converts between measurement units.", + instructions: """ + Use this skill to convert values between metric and imperial units. + Refer to the conversion-table resource for supported unit pairs. + Run the convert script to perform conversions. + """ + ) + .AddResource("kg=2.205lb, m=3.281ft, L=0.264gal", "conversion-table", "Supported unit pairs") + .AddScript(Convert, "convert", "Converts a value between units"); + +var source = new AgentInMemorySkillsSource([skill]); + +var provider = new AgentSkillsProvider(source); + +AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions +{ + AIContextProviders = [provider], +}); + +static string Convert(double value, double factor) + => JsonSerializer.Serialize(new { result = Math.Round(value * factor, 4) }); +``` + +#### Class-Based Skills + +Class-based skills are designed for packaging skills as reusable libraries. Users subclass `AgentClassSkill` and override properties. Unlike inline skills, class-based skills are self-contained, can live in shared libraries or NuGet packages, and are well-suited for dependency injection. + +**`AgentClassSkill`** — An abstract base class for defining skills as reusable C# classes that bundle all skill components (frontmatter, instructions, resources, scripts) together. Designed for packaging skills as distributable libraries: + +```csharp +public abstract class AgentClassSkill : AgentSkill +{ + public abstract string Instructions { get; } + + // Content is auto-synthesized from Frontmatter + Instructions + Resources + Scripts + public override string Content => + SkillContentBuilder.BuildContent(Frontmatter.Name, Frontmatter.Description, + SkillContentBuilder.BuildBody(Instructions, Resources, Scripts)); +} +``` + +**Example** — Defining a class-based skill and adding it to a source: + +```csharp +public class UnitConverterSkill : AgentClassSkill +{ + public override AgentSkillFrontmatter Frontmatter { get; } = + new("unit-converter", "Converts between measurement units."); + + public override string Instructions => """ + Use this skill to convert values between metric and imperial units. + Refer to the conversion-table resource for supported unit pairs. + Run the convert script to perform conversions. + """; + + public override IReadOnlyList? Resources { get; } = + [ + new AgentInlineSkillResource("kg=2.205lb, m=3.281ft", "conversion-table"), + ]; + + public override IReadOnlyList? Scripts { get; } = + [ + new AgentInlineSkillScript(Convert, "convert"), + ]; + + private static string Convert(double value, double factor) + => JsonSerializer.Serialize(new { result = Math.Round(value * factor, 4) }); +} + +var source = new AgentInMemorySkillsSource([new UnitConverterSkill()]); + +var provider = new AgentSkillsProvider(source); + +AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions +{ + AIContextProviders = [provider], +}); +``` + +## Filtering, Caching, and Deduplication + +The following subsections present alternative approaches for handling filtering, caching, and deduplication of skills across multiple sources. + +### Via Composition + +In this approach, the `AgentSkillsProvider` accepts a **single** `AgentSkillsSource`. Multiple sources are composed externally via an aggregate source, and cross-cutting concerns like filtering, caching, and deduplication are implemented as **source decorators** — subclasses of `DelegatingAgentSkillsSource` that intercept `GetSkillsAsync()`. + +**`FilteringAgentSkillsSource`** — A decorator that applies filter logic before returning results. The decorator pattern keeps filtering orthogonal to source implementations and allows composing multiple filters: + +```csharp +public sealed class FilteringAgentSkillsSource : DelegatingAgentSkillsSource +{ + private readonly Func _predicate; + + public FilteringAgentSkillsSource(AgentSkillsSource innerSource, Func predicate) + : base(innerSource) + { + _predicate = predicate; + } + + public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + var skills = await this.InnerSource.GetSkillsAsync(cancellationToken); + return skills.Where(_predicate).ToList(); + } +} +``` + +**`CachingAgentSkillsSource`** — A decorator that caches skills after the first load, keeping the provider stateless and giving consumers control over caching granularity per source. For example, file-based skills (expensive to discover) can be cached while code-defined skills remain uncached: + +```csharp +public sealed class CachingAgentSkillsSource : DelegatingAgentSkillsSource +{ + private IList? _cached; + + public CachingAgentSkillsSource(AgentSkillsSource innerSource) + : base(innerSource) + { + } + + public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + return _cached ??= await this.InnerSource.GetSkillsAsync(cancellationToken); + } +} +``` + +**Deduplication** is similarly implemented as a decorator (`DeduplicatingAgentSkillsSource`) that deduplicates by name (case-insensitive, first-one-wins) and logs a warning for skipped duplicates. + +**Example** — Combining file-based and code-defined sources with filtering and caching: + +```csharp +var fileSource = new CachingAgentSkillsSource(new AgentFileSkillsSource(["./skills"])); +var codeSource = new AgentInMemorySkillsSource([myCodeSkill]); + +var compositeSource = new FilteringAgentSkillsSource( + new AggregatingAgentSkillsSource([fileSource, codeSource]), + filter: s => s.Frontmatter.Name != "internal"); + +var provider = new AgentSkillsProvider(compositeSource); + +AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions +{ + AIContextProviders = [provider], +}); +``` + +**Pros:** +- Clean single-responsibility: the provider serves skills, sources provide them. +- Caching, filtering, and deduplication are composable as source decorators — each concern is a separate, testable wrapper. + +**Cons:** +- DI is less flexible: multiple `AgentSkillsSource` implementations registered in the container cannot be auto-injected into the provider. The consumer must manually compose them via an aggregate source. +- Increased public API surface: requires additional public classes (aggregate source, caching decorators, filtering decorators) that consumers need to learn and use. + +### Via AgentSkillsProvider + +In this approach, the `AgentSkillsProvider` accepts **`IEnumerable`** and handles aggregation, filtering, caching, and deduplication internally. + +The provider aggregates skills from all registered sources, deduplicates by name (case-insensitive, first-one-wins), caches the result after the first load, and optionally applies filtering via a predicate on `AgentSkillsProviderOptions`. Duplicate skill names are logged as warnings. + +**Example** — Registering multiple sources directly with the provider: + +```csharp +// Conceptual example — in practice, use AgentSkillsProviderBuilder +var fileSource = new AgentFileSkillsSource(["./skills"]); +var codeSource = new AgentInMemorySkillsSource([myCodeSkill]); + +var provider = new AgentSkillsProvider( + sources: [fileSource, codeSource], + options: new AgentSkillsProviderOptions + { + Filter = s => s.Frontmatter.Name != "internal", + }); + +AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions +{ + AIContextProviders = [provider], +}); +``` + +**Pros:** +- DI-friendly: register multiple `AgentSkillsSource` implementations in the container, and they are all auto-injected into `AgentSkillsProvider` via `IEnumerable`. +- Smaller public API surface: no need for aggregate source, caching decorators, or filtering decorator classes — these concerns are handled internally by the provider. + +**Cons:** +- The provider takes on multiple responsibilities — aggregation, caching, deduplication, and filtering. +- Less granular caching control: caching is all-or-nothing across sources rather than per-source as with decorators. +- Less extensible: new behaviors (e.g., ordering, TTL expiration) require modifying the provider rather than adding a decorator. + +### Builder Pattern + +**`AgentSkillsProviderBuilder`** provides a fluent API for composing skills from multiple sources. The builder centralizes configuration — script executors, approval callbacks, prompt templates, and filtering — so consumers don't need to know the underlying source types. + +The builder internally decides how to wire up the object graph: it creates the appropriate source instances, applies caching and filtering, and returns a fully configured `AgentSkillsProvider`. This keeps the setup code concise while still allowing fine-grained control when needed. + +**Example** — Using the builder to combine multiple source types with configuration: + +```csharp +var provider = new AgentSkillsProviderBuilder() + .UseFileSkill("./skills") // file-based source + .UseInlineSkills(codeSkill) // code-defined source + .UseClassSkills(new ClassSkill()) // class-based source + .UseFileScriptRunner(SubprocessScriptRunner.RunAsync) // script runner + .UseScriptApproval() // optional human-in-the-loop + .UsePromptTemplate(customTemplate) // optional prompt customization + .UseFilter(s => s.Frontmatter.Name != "internal") // optional skill filtering + .Build(); + +AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions +{ + AIContextProviders = [provider], +}); +``` + +## Adding a Custom Skill Type + +The skills framework is designed for extensibility. While file-based and inline skills cover common +scenarios, you can introduce entirely new skill types by subclassing the four base classes: + +| Base class | Purpose | +|-----------------------|-----------------------------------------------------| +| `AgentSkillsSource` | Discovers and loads skills from a particular origin | +| `AgentSkill` | Holds metadata, content, resources, and scripts | +| `AgentSkillResource` | Provides supplementary content to a skill | +| `AgentSkillScript` | Represents an executable action within a skill | + +The example below implements a **cloud-based skill type** where skills, resources, and scripts are +all stored in and executed through a remote cloud service (e.g., Azure Blob Storage + Azure Functions). + +### Step 1 — Define a custom resource + +A `CloudSkillResource` reads resource content from a cloud storage endpoint instead of the local +filesystem: + +```csharp +/// +/// A skill resource backed by a cloud storage endpoint. +/// +public sealed class CloudSkillResource : AgentSkillResource +{ + private readonly HttpClient _httpClient; + + public CloudSkillResource(string name, Uri blobUri, HttpClient httpClient, string? description = null) + : base(name, description) + { + BlobUri = blobUri ?? throw new ArgumentNullException(nameof(blobUri)); + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + /// + /// Gets the URI of the cloud blob that holds this resource's content. + /// + public Uri BlobUri { get; } + + /// + public override async Task ReadAsync( + IServiceProvider? serviceProvider = null, + CancellationToken cancellationToken = default) + { + return await _httpClient.GetStringAsync(BlobUri, cancellationToken).ConfigureAwait(false); + } +} +``` + +### Step 2 — Define a custom script + +A `CloudSkillScript` executes a script by calling a cloud function endpoint, passing arguments as +the request body: + +```csharp +/// +/// A skill script executed via a cloud function endpoint. +/// +public sealed class CloudSkillScript : AgentSkillScript +{ + private readonly HttpClient _httpClient; + + public CloudSkillScript(string name, Uri functionUri, HttpClient httpClient, string? description = null) + : base(name, description) + { + FunctionUri = functionUri ?? throw new ArgumentNullException(nameof(functionUri)); + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + /// + /// Gets the URI of the cloud function that runs this script. + /// + public Uri FunctionUri { get; } + + /// + public override async Task RunAsync( + AgentSkill skill, + AIFunctionArguments arguments, + CancellationToken cancellationToken = default) + { + var json = JsonSerializer.Serialize(arguments); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + var response = await _httpClient.PostAsync(FunctionUri, content, cancellationToken) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + } +} +``` + +### Step 3 — Define a custom skill + +A `CloudSkill` bundles cloud-specific metadata (e.g., the base endpoint) with the standard skill +shape: + +```csharp +/// +/// An whose content, resources, and scripts are stored in a cloud service. +/// +public sealed class CloudSkill : AgentSkill +{ + public CloudSkill( + AgentSkillFrontmatter frontmatter, + string content, + Uri endpoint, + IReadOnlyList? resources = null, + IReadOnlyList? scripts = null) + { + Frontmatter = frontmatter ?? throw new ArgumentNullException(nameof(frontmatter)); + Content = content ?? throw new ArgumentNullException(nameof(content)); + Endpoint = endpoint ?? throw new ArgumentNullException(nameof(endpoint)); + Resources = resources; + Scripts = scripts; + } + + /// + public override AgentSkillFrontmatter Frontmatter { get; } + + /// + public override string Content { get; } + + /// + /// Gets the base cloud endpoint for this skill. + /// + public Uri Endpoint { get; } + + /// + public override IReadOnlyList? Resources { get; } + + /// + public override IReadOnlyList? Scripts { get; } +} +``` + +### Step 4 — Define a custom source + +A `CloudSkillsSource` discovers skills from a cloud catalog API and constructs `CloudSkill` +instances with their associated resources and scripts: + +```csharp +/// +/// A skill source that discovers and loads skills from a cloud catalog API. +/// +public sealed class CloudSkillsSource : AgentSkillsSource +{ + private readonly Uri _catalogUri; + private readonly HttpClient _httpClient; + + public CloudSkillsSource(Uri catalogUri, HttpClient httpClient) + { + _catalogUri = catalogUri ?? throw new ArgumentNullException(nameof(catalogUri)); + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + /// + public override async Task> GetSkillsAsync( + CancellationToken cancellationToken = default) + { + // Fetch the skill catalog from the cloud service. + var json = await _httpClient.GetStringAsync(_catalogUri, cancellationToken) + .ConfigureAwait(false); + var catalog = JsonSerializer.Deserialize(json)!; + + var skills = new List(); + + foreach (var entry in catalog.Skills) + { + var frontmatter = new AgentSkillFrontmatter(entry.Name, entry.Description); + + // Build cloud-backed resources. + var resources = entry.Resources + .Select(r => new CloudSkillResource(r.Name, r.BlobUri, _httpClient, r.Description)) + .ToList(); + + // Build cloud-backed scripts. + var scripts = entry.Scripts + .Select(s => new CloudSkillScript(s.Name, s.FunctionUri, _httpClient, s.Description)) + .ToList(); + + skills.Add(new CloudSkill(frontmatter, entry.Content, entry.Endpoint, resources, scripts)); + } + + return skills; + } +} +``` + +### Step 5 — Register with the builder + +Use `UseSource` to wire the custom source into the provider: + +```csharp +var httpClient = new HttpClient(); + +var provider = new AgentSkillsProviderBuilder() + .UseSource(new CloudSkillsSource( + new Uri("https://my-service.example.com/skills/catalog"), + httpClient)) + // Mix with other source types if needed: + .UseFileSkill("/local/skills", scriptRunner) + .UseInlineSkills(someInlineSkill) + .Build(); +``` + +The `AgentSkillsProvider` handles all skill types uniformly — any combination of file-based, inline, +class-based, and custom skills can coexist in the same provider. Custom skills automatically +participate in the model-facing tools (`load_skill`, `read_skill_resource`, `run_skill_script`), +filtering, deduplication, and caching — no additional integration work is required. + +## Script Representation: `AgentSkillScript` vs `AIFunction` + +Two approaches were considered for representing executable scripts within skills: + +### Option A — Custom `AgentSkillScript` abstract base class (original design) + +Scripts are modeled as a custom `AgentSkillScript` abstract class with `Name`, `Description`, and +`RunAsync(AgentSkill, AIFunctionArguments, CancellationToken)`. Concrete implementations: +`AgentInlineSkillScript` (wraps a delegate/`AIFunction`) and `AgentFileSkillScript` (wraps a file path + executor delegate). + +```csharp +// Base type +public abstract class AgentSkillScript +{ + public string Name { get; } + public string? Description { get; } + public abstract Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default); +} + +// AgentSkill exposes scripts as: +public abstract IReadOnlyList? Scripts { get; } + +// Inline script wraps an AIFunction internally +var script = new AgentInlineSkillScript(ConvertUnits, "convert"); + +// Pre-built AIFunction must be wrapped +var script = new AgentInlineSkillScript(myAIFunction); + +// Class-based skill declares scripts as: +public override IReadOnlyList? Scripts { get; } = +[ + new AgentInlineSkillScript(ConvertUnits, "convert"), +]; + +// Provider executes scripts by passing the owning skill: +await script.RunAsync(skill, arguments, cancellationToken); +``` + +**Pros:** + +- **Explicit skill context at execution time.** `RunAsync` receives the owning `AgentSkill`, so any script can access skill metadata or resources during execution without requiring construction-time wiring. +- **Self-contained abstraction.** A dedicated type communicates clearly that scripts are a skills-framework concept, separate from general-purpose AI functions. +- **Easier extensibility for custom script types.** Third-party implementations can subclass `AgentSkillScript` and access the owning skill in `RunAsync` without special setup. + +**Cons:** + +- **Wrapper overhead.** `AgentInlineSkillScript` is a thin pass-through around `AIFunction` — it adds a class, a constructor, and an indirection layer for no behavioral difference. +- **Parallel abstraction.** `AgentSkillScript` and `AIFunction` serve overlapping purposes (named callable with arguments), creating two parallel hierarchies for the same concept. +- **Friction for consumers.** Users who already have `AIFunction` instances must wrap them in `AgentInlineSkillScript` to use them as scripts, adding ceremony. + +### Option B — Reuse `AIFunction` directly + +Scripts are represented as `AIFunction` (from `Microsoft.Extensions.AI`). `AgentSkill.Scripts` returns +`IReadOnlyList?`. `AgentInlineSkillScript` is eliminated entirely — callers use +`AIFunctionFactory.Create(delegate, name: ...)` or pass `AIFunction` instances directly. +`AgentFileSkillScript` becomes an `AIFunction` subclass that captures its owning `AgentFileSkill` via +an internal back-reference set during construction. + +```csharp +// AgentSkill exposes scripts as AIFunction directly: +public abstract IReadOnlyList? Scripts { get; } + +// Inline scripts use AIFunctionFactory — no wrapper class needed +var skill = new AgentInlineSkill("my-skill", "desc", "instructions"); +skill.AddScript(ConvertUnits, "convert"); // delegate +skill.AddScript(myAIFunction); // pre-built AIFunction — no wrapping + +// Class-based skill declares scripts as: +public override IReadOnlyList? Scripts { get; } = +[ + AIFunctionFactory.Create(ConvertUnits, name: "convert"), +]; + +// Provider executes scripts via standard AIFunction invocation: +await script.InvokeAsync(arguments, cancellationToken); + +// File-based scripts extend AIFunction and capture the owning skill internally: +public sealed class AgentFileSkillScript : AIFunction +{ + internal AgentFileSkill? Skill { get; set; } // set by AgentFileSkill constructor + + protected override async ValueTask InvokeCoreAsync( + AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await _executor(Skill!, this, arguments, cancellationToken); + } +} +``` + +**Pros:** + +- **Fewer types.** Eliminates `AgentSkillScript` and `AgentInlineSkillScript`, reducing the public API surface by two classes. +- **Seamless interop.** Any `AIFunction` — whether from `AIFunctionFactory`, a custom subclass, or an external library — can be used as a skill script with zero wrapping. +- **Consistent with `Microsoft.Extensions.AI` ecosystem.** Scripts share the same type as tool functions used by `IChatClient` and `FunctionInvokingChatClient`, reducing conceptual overhead for developers already familiar with the ecosystem. + +**Cons:** + +- **No owning-skill context in invocation signature.** `AIFunction.InvokeAsync` does not accept an `AgentSkill` parameter, so `AgentFileSkillScript` must capture its owning skill via an internal setter during construction. This adds a construction-order dependency: the skill must set the back-reference on its scripts. +- **Custom script types lose automatic skill access.** Third-party `AIFunction` subclasses that need the owning skill must implement their own mechanism (e.g., constructor injection, closure capture) instead of receiving it as a method parameter. +- **Semantic overloading.** `AIFunction` now means both "a tool the model can call" and "a script within a skill", which could blur the distinction for framework users. + +## Resource Representation: `AgentSkillResource` vs `AIFunction` + +Two approaches were considered for representing skill resources (supplementary content such as references, assets, or dynamic data): + +### Option A — Custom `AgentSkillResource` abstract base class (original design) + +Resources are modeled as a custom `AgentSkillResource` abstract class with `Name`, `Description`, and +`ReadAsync(IServiceProvider?, CancellationToken)`. Concrete implementations: +`AgentInlineSkillResource` (static value, delegate, or `AIFunction` wrapper) and `AgentFileSkillResource` (reads file content from disk). + +```csharp +// Base type +public abstract class AgentSkillResource +{ + public string Name { get; } + public string? Description { get; } + public abstract Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default); +} + +// AgentSkill exposes resources as: +public abstract IReadOnlyList? Resources { get; } + +// Static resource +var resource = new AgentInlineSkillResource("static content", "my-resource"); + +// Dynamic resource (delegate) +var resource = new AgentInlineSkillResource((IServiceProvider sp) => GetData(sp), "my-resource"); + +// Pre-built AIFunction must be wrapped +var resource = new AgentInlineSkillResource(myAIFunction); + +// Class-based skill declares resources as: +public override IReadOnlyList? Resources { get; } = +[ + new AgentInlineSkillResource("# Conversion Tables\n...", "conversion-table"), +]; + +// Provider reads resources via: +await resource.ReadAsync(serviceProvider, cancellationToken); +``` + +**Pros:** + +- **Clear semantic distinction.** A dedicated `AgentSkillResource` type distinguishes resources (data providers) from scripts (executable actions), making the API self-documenting. +- **Purpose-built API.** `ReadAsync` communicates intent better than `InvokeAsync` for a data-access operation. + +**Cons:** + +- **Wrapper overhead.** `AgentInlineSkillResource` wraps `AIFunction` internally for delegate/function cases — adding a class and indirection for no behavioral difference. +- **Parallel abstraction.** `AgentSkillResource` and `AIFunction` serve overlapping purposes (named callable that returns data), creating two parallel hierarchies. +- **Friction for consumers.** Users who already have `AIFunction` instances must wrap them in `AgentInlineSkillResource`, adding ceremony. + +### Option B — Reuse `AIFunction` directly + +Resources are represented as `AIFunction`. `AgentSkill.Resources` returns `IReadOnlyList?`. +`AgentInlineSkillResource` becomes an `AIFunction` subclass (retained as a convenience for the static-value +pattern: `new AgentInlineSkillResource("data", "name")`). `AgentFileSkillResource` becomes an `AIFunction` +subclass that reads file content. + +```csharp +// AgentSkill exposes resources as AIFunction directly: +public abstract IReadOnlyList? Resources { get; } + +// Static resource — AgentInlineSkillResource is retained as a convenience AIFunction subclass +var resource = new AgentInlineSkillResource("static content", "my-resource"); + +// Dynamic resource — AgentInlineSkillResource wraps delegate as AIFunction +var resource = new AgentInlineSkillResource((IServiceProvider sp) => GetData(sp), "my-resource"); + +// Pre-built AIFunction can be used directly — no wrapping needed +skill.AddResource(myAIFunction); + +// Class-based skill declares resources as: +public override IReadOnlyList? Resources { get; } = +[ + new AgentInlineSkillResource("# Conversion Tables\n...", "conversion-table"), +]; + +// Provider reads resources via standard AIFunction invocation: +await resource.InvokeAsync(arguments, cancellationToken); + +// File-based resources extend AIFunction directly: +internal sealed class AgentFileSkillResource : AIFunction +{ + public string FullPath { get; } + + protected override async ValueTask InvokeCoreAsync( + AIFunctionArguments arguments, CancellationToken cancellationToken) + { + return await File.ReadAllTextAsync(FullPath, Encoding.UTF8, cancellationToken); + } +} +``` + +**Pros:** + +- **Fewer base types.** Eliminates the `AgentSkillResource` abstract class, reducing the public API surface. +- **Seamless interop.** Any `AIFunction` can be used as a skill resource with zero wrapping. + +**Cons:** + +- **Loss of semantic distinction.** Resources and scripts are now both `AIFunction`, which could make it less obvious which list a function belongs to when reading code. +- **Static values require a wrapper.** Unlike the original `ReadAsync` which could return a stored value directly, `AIFunction.InvokeAsync` implies invocation. `AgentInlineSkillResource` is retained as a convenience subclass to handle the static-value case, so this is not eliminated — just moved to a different class. + +## Decision Outcome + +### 1. Keep `AgentSkillResource` and `AgentSkillScript` (Option A for both sections) + +We are staying with the custom `AgentSkillResource` and `AgentSkillScript` model classes instead of reusing `AIFunction`: + +- **Resources have no parameters.** If a consumer provides an `AIFunction` with parameters, those parameters will never be advertised to the LLM, and the resulting call will fail. +- **Approval breaks for `AIFunction`-based representations.** When a resource or script represented by an `AIFunction` is configured with approval, the second approval invocation will not work correctly. +- **Injecting the owning skill into an `AIFunction`-based script is problematic.** Constructor injection would introduce a circular reference between the skill and the script. An internal property setter is possible but adds coupling. + +### 2. Make all agent skill classes internal + +All agent-skill-related classes are made `internal` to minimize the public API surface while the feature matures. We can reconsider and promote types to `public` later based on community signal. + +This leaves two public entry points: + +- **`AgentSkillsProvider`** — use directly when all skills come from a single source and filtering is not needed. +- **`AgentSkillsProviderBuilder`** — use when mixing skill types or when filtering support is required. + +### 3. Caching at provider level + +Caching of tools and instructions is implemented inside `AgentSkillsProvider` rather than as an external decorator. Recreating tools and instructions on every provider call is wasteful, and a caching decorator sitting outside the provider would not have the information needed to cache them effectively. From 84fe6c46ab89ddfbc21fc5939f3c4d3c42aab6cb Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:19:32 +0000 Subject: [PATCH 09/22] .NET: Add AsIChatClientWithStoredOutputDisabled for ProjectResponsesClient (#4911) * Add AsIChatClientWithStoredOutputDisabled for ProjectResponsesClient Add extension method on ProjectResponsesClient in Microsoft.Agents.AI.AzureAI package (Azure.AI.Extensions.OpenAI namespace) mirroring the existing extension on ResponsesClient in the OpenAI package. This enables Azure AI consumers to disable server-side response storage without depending on the OpenAI package. - New ProjectResponsesClientExtensions class with AsIChatClientWithStoredOutputDisabled - Optional deploymentName parameter (model is no longer required) - Updated OpenAI counterpart doc to remove 'Required' wording for model param - Added unit tests covering null guard, inner client accessibility, StoredOutputEnabled=false, and reasoning encrypted content inclusion/exclusion Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Preserve existing RawRepresentationFactory when disabling stored output Address PR review feedback: wrap/chain the existing factory instead of replacing it, so upstream configuration (e.g., deploymentName/model defaults from AsIChatClient) is preserved. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ProjectResponsesClientExtensions.cs | 60 +++++++ .../OpenAIResponseClientExtensions.cs | 2 +- .../ProjectResponsesClientExtensionsTests.cs | 169 ++++++++++++++++++ 3 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.AzureAI/ProjectResponsesClientExtensions.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/ProjectResponsesClientExtensionsTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/ProjectResponsesClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/ProjectResponsesClientExtensions.cs new file mode 100644 index 0000000000..5a899d5076 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/ProjectResponsesClientExtensions.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; +using OpenAI.Responses; + +namespace Azure.AI.Extensions.OpenAI; + +/// +/// Provides extension methods for +/// to simplify the creation of AI agents that work with Azure AI services. +/// +[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] +public static class ProjectResponsesClientExtensions +{ + /// + /// Gets an for use with this that does not store responses for later retrieval. + /// + /// + /// This corresponds to setting the "store" property in the JSON representation to false. + /// + /// The client. + /// Optional deployment name (model) to use for requests. + /// + /// Includes an encrypted version of reasoning tokens in reasoning item outputs. + /// This enables reasoning items to be used in multi-turn conversations when using the Responses API statelessly + /// (like when the store parameter is set to false, or when an organization is enrolled in the zero data retention program). + /// Defaults to . + /// + /// An that can be used to converse via the that does not store responses for later retrieval. + /// is . + [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] + public static IChatClient AsIChatClientWithStoredOutputDisabled(this ProjectResponsesClient responseClient, string? deploymentName = null, bool includeReasoningEncryptedContent = true) + { + return Throw.IfNull(responseClient) + .AsIChatClient(deploymentName) + .AsBuilder() + .ConfigureOptions(x => + { + var previousFactory = x.RawRepresentationFactory; + x.RawRepresentationFactory = state => + { + var responseOptions = previousFactory?.Invoke(state) as CreateResponseOptions ?? new CreateResponseOptions(); + + responseOptions.StoredOutputEnabled = false; + + if (includeReasoningEncryptedContent && + !responseOptions.IncludedProperties.Contains(IncludedResponseProperty.ReasoningEncryptedContent)) + { + responseOptions.IncludedProperties.Add(IncludedResponseProperty.ReasoningEncryptedContent); + } + + return responseOptions; + }; + }) + .Build(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs index 5aee8eb046..4ceff75743 100644 --- a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs @@ -105,7 +105,7 @@ public static class OpenAIResponseClientExtensions /// This corresponds to setting the "store" property in the JSON representation to false. /// /// The client. - /// Optional default model ID to use for requests. Required when using a plain (not via Azure OpenAI). + /// Optional default model ID to use for requests. /// /// Includes an encrypted version of reasoning tokens in reasoning item outputs. /// This enables reasoning items to be used in multi-turn conversations when using the Responses API statelessly diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/ProjectResponsesClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/ProjectResponsesClientExtensionsTests.cs new file mode 100644 index 0000000000..d10ef861c9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/ProjectResponsesClientExtensionsTests.cs @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Reflection; +using Azure.AI.Extensions.OpenAI; +using Microsoft.Extensions.AI; +using OpenAI.Responses; + +namespace Microsoft.Agents.AI.AzureAI.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class ProjectResponsesClientExtensionsTests +{ + private static ProjectResponsesClient CreateTestClient() + { + return new ProjectResponsesClient(new FakeAuthenticationTokenProvider()); + } + + /// + /// Verify that AsIChatClientWithStoredOutputDisabled throws ArgumentNullException when client is null. + /// + [Fact] + public void AsIChatClientWithStoredOutputDisabled_WithNullClient_ThrowsArgumentNullException() + { + // Act & Assert + var exception = Assert.Throws(() => + ((ProjectResponsesClient)null!).AsIChatClientWithStoredOutputDisabled()); + + Assert.Equal("responseClient", exception.ParamName); + } + + /// + /// Verify that AsIChatClientWithStoredOutputDisabled wraps the original ProjectResponsesClient, + /// which remains accessible via the service chain. + /// + [Fact] + public void AsIChatClientWithStoredOutputDisabled_InnerResponsesClientIsAccessible() + { + // Arrange + var responseClient = CreateTestClient(); + + // Act + var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(); + + // Assert - the inner ProjectResponsesClient should be accessible via GetService + var innerClient = chatClient.GetService(); + Assert.NotNull(innerClient); + Assert.Same(responseClient, innerClient); + } + + /// + /// Verify that AsIChatClientWithStoredOutputDisabled with includeReasoningEncryptedContent false + /// wraps the original ProjectResponsesClient, which remains accessible via the service chain. + /// + [Fact] + public void AsIChatClientWithStoredOutputDisabled_WithIncludeReasoningFalse_InnerResponsesClientIsAccessible() + { + // Arrange + var responseClient = CreateTestClient(); + + // Act + var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(includeReasoningEncryptedContent: false); + + // Assert - the inner ProjectResponsesClient should be accessible via GetService + var innerClient = chatClient.GetService(); + Assert.NotNull(innerClient); + Assert.Same(responseClient, innerClient); + } + + /// + /// Verify that AsIChatClientWithStoredOutputDisabled with default parameter (includeReasoningEncryptedContent = true) + /// configures StoredOutputEnabled to false and includes ReasoningEncryptedContent in IncludedProperties. + /// + [Fact] + public void AsIChatClientWithStoredOutputDisabled_Default_ConfiguresStoredOutputDisabledWithReasoningEncryptedContent() + { + // Arrange + var responseClient = CreateTestClient(); + + // Act + var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(); + + // Assert + var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient); + Assert.NotNull(createResponseOptions); + Assert.False(createResponseOptions.StoredOutputEnabled); + Assert.Contains(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties); + } + + /// + /// Verify that AsIChatClientWithStoredOutputDisabled with includeReasoningEncryptedContent explicitly set to true + /// configures StoredOutputEnabled to false and includes ReasoningEncryptedContent in IncludedProperties. + /// + [Fact] + public void AsIChatClientWithStoredOutputDisabled_WithIncludeReasoningTrue_ConfiguresStoredOutputDisabledWithReasoningEncryptedContent() + { + // Arrange + var responseClient = CreateTestClient(); + + // Act + var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(includeReasoningEncryptedContent: true); + + // Assert + var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient); + Assert.NotNull(createResponseOptions); + Assert.False(createResponseOptions.StoredOutputEnabled); + Assert.Contains(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties); + } + + /// + /// Verify that AsIChatClientWithStoredOutputDisabled with includeReasoningEncryptedContent set to false + /// configures StoredOutputEnabled to false and does not include ReasoningEncryptedContent in IncludedProperties. + /// + [Fact] + public void AsIChatClientWithStoredOutputDisabled_WithIncludeReasoningFalse_ConfiguresStoredOutputDisabledWithoutReasoningEncryptedContent() + { + // Arrange + var responseClient = CreateTestClient(); + + // Act + var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(includeReasoningEncryptedContent: false); + + // Assert + var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient); + Assert.NotNull(createResponseOptions); + Assert.False(createResponseOptions.StoredOutputEnabled); + Assert.DoesNotContain(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties); + } + + /// + /// Verify that AsIChatClientWithStoredOutputDisabled works with an optional deployment name. + /// + [Fact] + public void AsIChatClientWithStoredOutputDisabled_WithDeploymentName_ConfiguresStoredOutputDisabled() + { + // Arrange + var responseClient = CreateTestClient(); + + // Act + var chatClient = responseClient.AsIChatClientWithStoredOutputDisabled(deploymentName: "my-deployment"); + + // Assert + var createResponseOptions = GetCreateResponseOptionsFromPipeline(chatClient); + Assert.NotNull(createResponseOptions); + Assert.False(createResponseOptions.StoredOutputEnabled); + Assert.Contains(IncludedResponseProperty.ReasoningEncryptedContent, createResponseOptions.IncludedProperties); + } + + /// + /// Extracts the produced by the ConfigureOptions pipeline + /// by using reflection to access the configure action and invoking it on a test . + /// + private static CreateResponseOptions? GetCreateResponseOptionsFromPipeline(IChatClient chatClient) + { + var configureField = chatClient.GetType().GetField("_configureOptions", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(configureField); + + var configureAction = configureField.GetValue(chatClient) as Action; + Assert.NotNull(configureAction); + + var options = new ChatOptions(); + configureAction(options); + + Assert.NotNull(options.RawRepresentationFactory); + return options.RawRepresentationFactory(chatClient) as CreateResponseOptions; + } +} From d2977d63dad06f06c01504a7b4b7312cf176779b Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:35:16 +0000 Subject: [PATCH 10/22] .NET: Add integration test validating OpenAPI tools with AsAIAgent(agentVersion) (#4931) * .NET: Add integration test for OpenAPI tools with AsAIAgent(agentVersion) Validates end-to-end flow creating a Foundry agent with an OpenAPI tool definition via native Azure.AI.Projects SDK types and wrapping it with AsAIAgent(agentVersion). The test confirms the server-side OpenAPI function is invoked correctly through RunAsync. Addresses #4883 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review: RetryFact, PascalCase naming, stronger tool assertion - Use RetryFact with Skip for manual testing (flaky due to external API) - Fix agentName -> AgentName to match PascalCase convention in file - Strengthen tool invocation assertion: require >= 3 Eurozone countries - Add comment explaining server-side OpenAPI tools don't surface as FunctionCallContent in the MEAI abstraction Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AIProjectClientCreateTests.cs | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs index e1cef1f1aa..425197853e 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs @@ -188,6 +188,134 @@ public class AIProjectClientCreateTests } } + /// + /// Validates that an agent version created with an OpenAPI tool definition via the native + /// Azure.AI.Projects SDK and then wrapped with AsAIAgent(agentVersion) correctly + /// invokes the server-side OpenAPI function through RunAsync. + /// Regression test for https://github.com/microsoft/agent-framework/issues/4883. + /// + [RetryFact(Constants.RetryCount, Constants.RetryDelay, Skip = "For manual testing only")] + public async Task AsAIAgent_WithOpenAPITool_NativeSDKCreation_InvokesServerSideToolAsync() + { + // Arrange — create agent version with OpenAPI tool using native Azure.AI.Projects SDK types. + string AgentName = AIProjectClientFixture.GenerateUniqueAgentName("OpenAPITestAgent"); + const string AgentInstructions = "You are a helpful assistant that can use the countries API to retrieve information about countries by their currency code."; + + const string CountriesOpenApiSpec = """ + { + "openapi": "3.1.0", + "info": { + "title": "REST Countries API", + "description": "Retrieve information about countries by currency code", + "version": "v3.1" + }, + "servers": [ + { + "url": "https://restcountries.com/v3.1" + } + ], + "paths": { + "/currency/{currency}": { + "get": { + "description": "Get countries that use a specific currency code (e.g., USD, EUR, GBP)", + "operationId": "GetCountriesByCurrency", + "parameters": [ + { + "name": "currency", + "in": "path", + "description": "Currency code (e.g., USD, EUR, GBP)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response with list of countries", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + }, + "404": { + "description": "No countries found for the currency" + } + } + } + } + } + } + """; + + // Step 1: Create the OpenAPI function definition and agent version using native SDK types. + var openApiFunction = new OpenApiFunctionDefinition( + "get_countries", + BinaryData.FromString(CountriesOpenApiSpec), + new OpenAPIAnonymousAuthenticationDetails()) + { + Description = "Retrieve information about countries by currency code" + }; + + var definition = new PromptAgentDefinition(model: TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName)) + { + Instructions = AgentInstructions, + Tools = { (ResponseTool)AgentTool.CreateOpenApiTool(openApiFunction) } + }; + + AgentVersionCreationOptions creationOptions = new(definition); + AgentVersion agentVersion = await this._client.Agents.CreateAgentVersionAsync(AgentName, creationOptions); + + try + { + // Step 2: Wrap the agent version using AsAIAgent extension. + ChatClientAgent agent = this._client.AsAIAgent(agentVersion); + + // Assert the agent was created correctly and retains version metadata. + Assert.NotNull(agent); + Assert.Equal(AgentName, agent.Name); + var retrievedVersion = agent.GetService(); + Assert.NotNull(retrievedVersion); + + // Step 3: Call RunAsync to trigger the server-side OpenAPI function. + var result = await agent.RunAsync("What countries use the Euro (EUR) as their currency? Please list them."); + + // Step 4: Validate the OpenAPI tool was invoked server-side. + // Note: Server-side OpenAPI tools (executed within the Responses API via AgentReference) + // do not surface as FunctionCallContent in the MEAI abstraction — the API handles the full + // tool loop internally. We validate tool invocation by asserting the response contains + // multiple specific country names that the model would need API data to enumerate accurately. + var text = result.ToString(); + Assert.NotEmpty(text); + + // The response must mention multiple well-known Eurozone countries — requiring several + // correct entries makes it highly unlikely the model answered purely from parametric knowledge. + int matchCount = 0; + foreach (var country in new[] { "Germany", "France", "Italy", "Spain", "Portugal", "Netherlands", "Belgium", "Austria", "Ireland", "Finland" }) + { + if (text.Contains(country, StringComparison.OrdinalIgnoreCase)) + { + matchCount++; + } + } + + Assert.True( + matchCount >= 3, + $"Expected response to list at least 3 Eurozone countries from the OpenAPI tool, but found {matchCount}. Response: {text}"); + } + finally + { + // Cleanup. + await this._client.Agents.DeleteAgentAsync(AgentName); + } + } + [Theory] [InlineData("CreateWithChatClientAgentOptionsAsync")] public async Task CreateAgent_CreatesAgentWithAIFunctionToolsAsync(string createMechanism) From 9691c9c27100bb9de5ff721d78dc9e7473f6060e Mon Sep 17 00:00:00 2001 From: AkiKurisu <2683987717@qq.com> Date: Fri, 27 Mar 2026 00:13:00 +0800 Subject: [PATCH 11/22] .NET: Fix role assignment in ChatMessage construction (#4290) * Use actual message role when creating ChatMessage Replace hard-coded ChatRole.User with a ChatRole constructed from the message's Role. The change ensures ToChatMessage and FunctionMessage use the original role (new ChatRole(this.Role)) for both text and contents branches, fixing incorrect role assignment when constructing ChatMessage instances. * Update changes * Fix formatting in ToChatMessage tests --------- Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> --- .../Models/ChatCompletionRequestMessage.cs | 10 +- ...pletionRequestMessageToChatMessageTests.cs | 115 ++++++++++++++++++ 2 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ChatCompletionRequestMessageToChatMessageTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionRequestMessage.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionRequestMessage.cs index 3e9483c616..2433eacbe0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionRequestMessage.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionRequestMessage.cs @@ -39,14 +39,16 @@ internal abstract record ChatCompletionRequestMessage /// Thrown when the content is neither text nor AI contents. public virtual ChatMessage ToChatMessage() { + var role = new ChatRole(this.Role); + if (this.Content.IsText) { - return new(ChatRole.User, this.Content.Text); + return new(role, this.Content.Text); } else if (this.Content.IsContents) { var aiContents = this.Content.Contents.Select(MessageContentPartConverter.ToAIContent).Where(c => c is not null).ToList(); - return new ChatMessage(ChatRole.User, aiContents!); + return new ChatMessage(role, aiContents!); } throw new InvalidOperationException("MessageContent has no value"); @@ -165,9 +167,11 @@ internal sealed record FunctionMessage : ChatCompletionRequestMessage /// Thrown when the content is not text. public override ChatMessage ToChatMessage() { + var role = new ChatRole(this.Role); + if (this.Content.IsText) { - return new(ChatRole.User, this.Content.Text); + return new(role, this.Content.Text); } throw new InvalidOperationException("FunctionMessage Content must be text"); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ChatCompletionRequestMessageToChatMessageTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ChatCompletionRequestMessageToChatMessageTests.cs new file mode 100644 index 0000000000..406f0a32e1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ChatCompletionRequestMessageToChatMessageTests.cs @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using System.Text.Json; +using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests; + +/// +/// Tests for ChatCompletionRequestMessage.ToChatMessage() role preservation. +/// Verifies that each message type correctly maps its role to the corresponding ChatRole. +/// +public sealed class ChatCompletionRequestMessageToChatMessageTests +{ + [Theory] + [InlineData("system", """{"role":"system","content":"You are a helpful assistant."}""")] + [InlineData("developer", """{"role":"developer","content":"Follow these rules."}""")] + [InlineData("user", """{"role":"user","content":"Hello!"}""")] + [InlineData("assistant", """{"role":"assistant","content":"Hi there!"}""")] + [InlineData("tool", """{"role":"tool","content":"result","tool_call_id":"call_123"}""")] + public void ToChatMessage_PreservesRole_ForTextContent(string expectedRole, string json) + { + // Arrange + ChatCompletionRequestMessage message = JsonSerializer.Deserialize( + json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionRequestMessage)!; + + // Act + ChatMessage chatMessage = message.ToChatMessage(); + + // Assert + Assert.Equal(expectedRole, message.Role); + Assert.Equal(new ChatRole(expectedRole), chatMessage.Role); + } + + [Fact] + public void ToChatMessage_FunctionMessage_PreservesRole() + { + // Arrange + const string Json = """{"role":"function","name":"get_weather","content":"sunny"}"""; + ChatCompletionRequestMessage message = JsonSerializer.Deserialize( + Json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionRequestMessage)!; + + // Act + ChatMessage chatMessage = message.ToChatMessage(); + + // Assert + Assert.Equal("function", message.Role); + Assert.Equal(new ChatRole("function"), chatMessage.Role); + } + + [Theory] + [InlineData("system")] + [InlineData("developer")] + [InlineData("user")] + [InlineData("assistant")] + public void ToChatMessage_PreservesRole_ForMultiPartContent(string expectedRole) + { + // Arrange + string json = $$"""{"role":"{{expectedRole}}","content":[{"type":"text","text":"Hello!"}]}"""; + ChatCompletionRequestMessage message = JsonSerializer.Deserialize( + json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionRequestMessage)!; + + // Act + ChatMessage chatMessage = message.ToChatMessage(); + + // Assert + Assert.Equal(expectedRole, message.Role); + Assert.Equal(new ChatRole(expectedRole), chatMessage.Role); + } + + [Fact] + public void ToChatMessage_MultiTurnConversation_PreservesAllRoles() + { + // Arrange - simulate a multi-turn conversation + string[] jsons = + [ + """{"role":"system","content":"You are a helpful assistant."}""", + """{"role":"user","content":"Hello!"}""", + """{"role":"assistant","content":"Hi there! How can I help?"}""", + """{"role":"user","content":"What did I just say?"}""" + ]; + + string[] expectedRoles = ["system", "user", "assistant", "user"]; + + // Act + ChatMessage[] chatMessages = jsons + .Select(j => JsonSerializer.Deserialize( + j, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionRequestMessage)!) + .Select(m => m.ToChatMessage()) + .ToArray(); + + // Assert + Assert.Equal(expectedRoles.Length, chatMessages.Length); + for (int i = 0; i < expectedRoles.Length; i++) + { + Assert.Equal(new ChatRole(expectedRoles[i]), chatMessages[i].Role); + } + } + + [Fact] + public void ToChatMessage_PreservesTextContent() + { + // Arrange + const string Json = """{"role":"system","content":"You are a helpful assistant."}"""; + ChatCompletionRequestMessage message = JsonSerializer.Deserialize( + Json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionRequestMessage)!; + + // Act + ChatMessage chatMessage = message.ToChatMessage(); + + // Assert + Assert.Contains(chatMessage.Contents, c => c is TextContent tc && tc.Text == "You are a helpful assistant."); + } +} From 3b8b56e6ea86a6d351248826f2097ac0f5bcecdb Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:45:01 +0000 Subject: [PATCH 12/22] Samples fix (#4932) --- .../Program.cs | 20 ++++++++++++++---- .../Agent_Step08_UsingImages.csproj | 6 ++++++ .../Assets/walkway.jpg | Bin 0 -> 37970 bytes .../Agent_Step08_UsingImages/Program.cs | 2 +- .../Program.cs | 2 +- 5 files changed, 24 insertions(+), 6 deletions(-) create mode 100644 dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Assets/walkway.jpg diff --git a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/Program.cs b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/Program.cs index 603f8b8e7b..a8dc73839a 100644 --- a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/Program.cs +++ b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/Program.cs @@ -73,16 +73,28 @@ foreach (ClientResult result in getConversationItemsResults.GetRawPages()) using JsonDocument getConversationItemsResultAsJson = JsonDocument.Parse(result.GetRawResponse().Content.ToString()); foreach (JsonElement element in getConversationItemsResultAsJson.RootElement.GetProperty("data").EnumerateArray()) { + // Skip non-message items (e.g. tool calls, reasoning) that lack a "role" property + if (!element.TryGetProperty("role"u8, out var roleElement)) + { + continue; + } + string messageId = element.GetProperty("id"u8).ToString(); - string messageRole = element.GetProperty("role"u8).ToString(); + string messageRole = roleElement.ToString(); Console.WriteLine($" Message ID: {messageId}"); Console.WriteLine($" Message Role: {messageRole}"); - foreach (var content in element.GetProperty("content").EnumerateArray()) + if (element.TryGetProperty("content"u8, out var contentElement)) { - string messageContentText = content.GetProperty("text"u8).ToString(); - Console.WriteLine($" Message Text: {messageContentText}"); + foreach (var content in contentElement.EnumerateArray()) + { + if (content.TryGetProperty("text"u8, out var textElement)) + { + Console.WriteLine($" Message Text: {textElement}"); + } + } } + Console.WriteLine(); } } diff --git a/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Agent_Step08_UsingImages.csproj b/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Agent_Step08_UsingImages.csproj index 73a41005f1..2b01c47354 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Agent_Step08_UsingImages.csproj +++ b/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Agent_Step08_UsingImages.csproj @@ -16,5 +16,11 @@ + + + + Always + + diff --git a/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Assets/walkway.jpg b/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Assets/walkway.jpg new file mode 100644 index 0000000000000000000000000000000000000000..13ef1e184087e7ac9fdb086d523a2bac486ce3f4 GIT binary patch literal 37970 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 literal 0 HcmV?d00001 diff --git a/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Program.cs index 984a9e3b5c..08e5b63139 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step08_UsingImages/Program.cs @@ -22,7 +22,7 @@ var agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential( ChatMessage message = new(ChatRole.User, [ new TextContent("What do you see in this image?"), - new UriContent("https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg", "image/jpeg") + await DataContent.LoadFromAsync("Assets/walkway.jpg"), ]); var session = await agent.CreateSessionAsync(); diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs index d44d62df51..d810c8046a 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs +++ b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs @@ -24,7 +24,7 @@ AIAgent agent = await aiProjectClient.CreateAIAgentAsync(name: VisionName, model ChatMessage message = new(ChatRole.User, [ new TextContent("What do you see in this image?"), - await DataContent.LoadFromAsync("assets/walkway.jpg"), + await DataContent.LoadFromAsync("Assets/walkway.jpg"), ]); AgentSession session = await agent.CreateSessionAsync(); From 63dee91a5fa08eab6e6c92009a613d117ba0b3aa Mon Sep 17 00:00:00 2001 From: Tao Chen Date: Thu, 26 Mar 2026 10:33:22 -0700 Subject: [PATCH 13/22] .NET: Improve observability sample (#4917) * Improve .Net observability sample * Update dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs b/dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs index 69d71e7b88..8e1f4245b6 100644 --- a/dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs +++ b/dotnet/samples/02-agents/AgentOpenTelemetry/Program.cs @@ -18,6 +18,7 @@ using OpenTelemetry.Trace; #region Setup Telemetry +// Source name for this sample's custom ActivitySource and Meter; other instrumentation uses their own sources/categories. const string SourceName = "OpenTelemetryAspire.ConsoleApp"; const string ServiceName = "AgentOpenTelemetry"; @@ -40,7 +41,6 @@ var resource = ResourceBuilder.CreateDefault() var tracerProviderBuilder = Sdk.CreateTracerProviderBuilder() .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(ServiceName, serviceVersion: "1.0.0")) .AddSource(SourceName) // Our custom activity source - .AddSource("*Microsoft.Agents.AI") // Agent Framework telemetry .AddHttpClientInstrumentation() // Capture HTTP calls to OpenAI .AddOtlpExporter(options => options.Endpoint = new Uri(otlpEndpoint)); @@ -54,8 +54,7 @@ using var tracerProvider = tracerProviderBuilder.Build(); // Setup metrics with resource and instrument name filtering using var meterProvider = Sdk.CreateMeterProviderBuilder() .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(ServiceName, serviceVersion: "1.0.0")) - .AddMeter(SourceName) // Our custom meter - .AddMeter("*Microsoft.Agents.AI") // Agent Framework metrics + .AddMeter(SourceName) // Our custom meter source .AddHttpClientInstrumentation() // HTTP client metrics .AddRuntimeInstrumentation() // .NET runtime metrics .AddOtlpExporter(options => options.Endpoint = new Uri(otlpEndpoint)) @@ -128,7 +127,7 @@ var agent = new ChatClientAgent(instrumentedChatClient, instructions: "You are a helpful assistant that provides concise and informative responses.", tools: [AIFunctionFactory.Create(GetWeatherAsync)]) .AsBuilder() - .UseOpenTelemetry(SourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the agent level + .UseOpenTelemetry(sourceName: SourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the agent level .Build(); var session = await agent.CreateSessionAsync(); From 3585581c7a6415db08832736b1d7a09cae6fcf22 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:45:46 +0000 Subject: [PATCH 14/22] .NET: Fix bug with per-service-call persistence and approvals (#4933) * Fix bug with per-service-call persistence and approvals * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../ChatClient/ChatClientAgent.cs | 27 ++ .../ChatHistoryPersistingChatClient.cs | 38 +++ .../ChatClient/ChatClientAgentTestHelper.cs | 259 +++++++++++++++ .../ChatClient/ChatClientAgentTests.cs | 23 +- .../ChatClientAgent_ApprovalsTests.cs | 306 ++++++++++++++++++ ...tClientAgent_ChatHistoryManagementTests.cs | 154 +++++++++ ...ChatClientAgent_ChatOptionsMergingTests.cs | 13 +- .../ChatHistoryPersistingChatClientTests.cs | 167 ++++++++++ 8 files changed, 971 insertions(+), 16 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTestHelper.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ApprovalsTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs index 6722bd8738..05caff4d83 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs @@ -788,6 +788,13 @@ public sealed partial class ChatClientAgent : AIAgent chatOptions.ConversationId = typedSession.ConversationId; } + // When per-service-call persistence is active, set a sentinel conversation ID so that + // FunctionInvokingChatClient treats locally-persisted history the same as service-managed + // history. This prevents it from adding duplicate FunctionCallContent messages into the + // request when processing approval responses — the loaded history already contains them. + // ChatHistoryPersistingChatClient strips the sentinel before forwarding to the inner client. + chatOptions = this.SetLocalHistoryConversationIdIfNeeded(chatOptions); + // Materialize the accumulated messages once at the end of the provider pipeline, reusing the existing list if possible. List messagesList = inputMessagesForChatClient as List ?? inputMessagesForChatClient.ToList(); @@ -929,6 +936,26 @@ public sealed partial class ChatClientAgent : AIAgent } } + /// + /// Sets the sentinel on + /// when per-service-call persistence is active and no real + /// conversation ID is present. + /// + /// + /// The (possibly new) with the sentinel set, or the original + /// if no sentinel is needed. + /// + private ChatOptions? SetLocalHistoryConversationIdIfNeeded(ChatOptions? chatOptions) + { + if (this.PersistsChatHistoryPerServiceCall && string.IsNullOrWhiteSpace(chatOptions?.ConversationId)) + { + chatOptions ??= new ChatOptions(); + chatOptions.ConversationId = ChatHistoryPersistingChatClient.LocalHistoryConversationId; + } + + return chatOptions; + } + /// /// Gets a value indicating whether the agent has a /// decorator in mark-only mode, which marks messages for later persistence at the end of the run. diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatHistoryPersistingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatHistoryPersistingChatClient.cs index 0085afbdd5..e733b778eb 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatHistoryPersistingChatClient.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatHistoryPersistingChatClient.cs @@ -50,6 +50,26 @@ internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient /// internal const string PersistedMarkerKey = "_chatHistoryPersisted"; + /// + /// A sentinel value set on by + /// when per-service-call persistence is active and no real conversation ID exists. + /// + /// + /// + /// This signals to that the chat history is being managed + /// externally (by this decorator), which prevents it from adding duplicate + /// messages into the request during approval-response processing. Without this sentinel, + /// would reconstruct function-call messages from approval + /// responses and append them to the original messages — but the loaded history already contains + /// those same function calls, causing duplicate tool-call entries that the model rejects. + /// + /// + /// This decorator strips the sentinel before forwarding requests to the inner client, so the + /// underlying model never sees it. + /// + /// + internal const string LocalHistoryConversationId = "_agent_local_history"; + /// /// Initializes a new instance of the class. /// @@ -87,6 +107,7 @@ internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient CancellationToken cancellationToken = default) { var (agent, session) = GetRequiredAgentAndSession(); + options = StripLocalHistoryConversationId(options); ChatResponse response; try @@ -130,6 +151,7 @@ internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient [EnumeratorCancellation] CancellationToken cancellationToken = default) { var (agent, session) = GetRequiredAgentAndSession(); + options = StripLocalHistoryConversationId(options); List responseUpdates = []; @@ -310,4 +332,20 @@ internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient } } } + + /// + /// If the carry the sentinel, + /// returns a clone with the conversation ID cleared so the inner client never sees it. + /// Otherwise returns the original unchanged. + /// + private static ChatOptions? StripLocalHistoryConversationId(ChatOptions? options) + { + if (options?.ConversationId == LocalHistoryConversationId) + { + options = options.Clone(); + options.ConversationId = null; + } + + return options; + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTestHelper.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTestHelper.cs new file mode 100644 index 0000000000..a3d2bd0c6a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTestHelper.cs @@ -0,0 +1,259 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Shared test helper for integration tests that verify +/// end-to-end behavior with and +/// . +/// +internal static class ChatClientAgentTestHelper +{ + /// + /// Represents an expected service call during a test: an optional input verifier and the response to return. + /// + /// The the mock service should return for this call. + /// Optional callback to verify the messages sent to the service on this call. +#pragma warning disable CA1812 // Instantiated by test classes + public sealed record ServiceCallExpectation( + ChatResponse Response, + Action>? VerifyInput = null); +#pragma warning restore CA1812 + + /// + /// Describes the expected shape of a message in the persisted history for structural comparison. + /// + /// The expected role of the message. + /// Optional substring that the message text should contain. + /// Optional array of expected types in the message. +#pragma warning disable CA1812 // Instantiated by test classes + public sealed record ExpectedMessage( + ChatRole Role, + string? TextContains = null, + Type[]? ContentTypes = null); +#pragma warning restore CA1812 + + /// + /// The result of a RunAsync invocation, containing the response, session, agent, + /// captured service inputs, and call counts for detailed verification. + /// + public sealed record RunResult( + AgentResponse Response, + ChatClientAgentSession Session, + ChatClientAgent Agent, + Mock MockService, + int TotalServiceCalls, + List> CapturedServiceInputs); + + /// + /// Creates a mock that returns responses in sequence, + /// captures input messages, and optionally verifies inputs. + /// + /// The ordered sequence of expected service calls. + /// Shared call index counter (allows reuse across multiple RunAsync calls). + /// List that captured service inputs are appended to. + /// The configured mock. + public static Mock CreateSequentialMock( + List expectations, + Ref callIndex, + List> capturedInputs) + { + Mock mock = new(); + mock.Setup(s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns, ChatOptions?, CancellationToken>((msgs, _, _) => + { + int idx = callIndex.Value++; + var messageList = msgs.ToList(); + capturedInputs.Add(messageList); + + if (idx >= expectations.Count) + { + throw new InvalidOperationException( + $"Mock received unexpected service call #{idx + 1}. Only {expectations.Count} call(s) were expected."); + } + + var expectation = expectations[idx]; + expectation.VerifyInput?.Invoke(messageList); + return Task.FromResult(expectation.Response); + }); + return mock; + } + + /// + /// Runs the agent with the given inputs, automatically verifying service call count + /// and optional expected history, and returns the result for further assertions. + /// + /// Messages to pass to RunAsync. + /// Ordered service call expectations for the mock. + /// Options for configuring the agent. If null, defaults are used. + /// An existing session to reuse (for multi-turn tests). If null, a new session is created. + /// An existing agent to reuse (for multi-turn tests). If null, a new agent is created. + /// An existing mock to reuse (for multi-turn tests). If null, a new mock is created. + /// Shared call index for multi-turn tests. If null, a new counter is created. + /// Shared captured inputs list for multi-turn tests. If null, a new list is created. + /// Optional initial chat history to pre-populate in . + /// Optional to pass to RunAsync. + /// + /// If provided, asserts the total number of service calls matches. + /// For multi-turn tests, pass null and verify after the final turn. + /// + /// + /// If provided, asserts that the persisted history matches these expected messages. + /// For multi-turn tests, pass null and verify after the final turn. + /// + /// A containing the response, session, agent, mock, and captured inputs. + public static async Task RunAsync( + List inputMessages, + List serviceCallExpectations, + ChatClientAgentOptions? agentOptions = null, + ChatClientAgentSession? existingSession = null, + ChatClientAgent? existingAgent = null, + Mock? existingMock = null, + Ref? callIndex = null, + List>? capturedInputs = null, + List? initialChatHistory = null, + AgentRunOptions? runOptions = null, + int? expectedServiceCallCount = null, + List? expectedHistory = null) + { + callIndex ??= new Ref(0); + capturedInputs ??= []; + var mock = existingMock ?? CreateSequentialMock(serviceCallExpectations, callIndex, capturedInputs); + agentOptions ??= new ChatClientAgentOptions(); + + var agent = existingAgent ?? new ChatClientAgent( + mock.Object, + options: agentOptions, + services: new ServiceCollection().BuildServiceProvider()); + + var session = existingSession ?? (await agent.CreateSessionAsync() as ChatClientAgentSession)!; + + // Pre-populate initial chat history if provided. + if (initialChatHistory is not null) + { + (agent.ChatHistoryProvider as InMemoryChatHistoryProvider) + ?.SetMessages(session, new List(initialChatHistory)); + } + + var response = await agent.RunAsync(inputMessages, session, runOptions); + + var result = new RunResult(response, session, agent, mock, callIndex.Value, capturedInputs); + + // Auto-verify service call count if specified. + if (expectedServiceCallCount.HasValue) + { + Assert.Equal(expectedServiceCallCount.Value, callIndex.Value); + } + + // Auto-verify persisted history if specified. + if (expectedHistory is not null) + { + var history = GetPersistedHistory(agent, session); + AssertMessagesMatch(history, expectedHistory); + } + + return result; + } + + /// + /// Asserts that the actual message list matches the expected message patterns structurally. + /// Checks message count, roles, optional text content, and optional content types. + /// + /// The actual messages to verify. + /// The expected message patterns. + public static void AssertMessagesMatch(List actual, List expected) + { + Assert.True( + expected.Count == actual.Count, + $"Expected {expected.Count} message(s) but found {actual.Count}.\nActual messages:\n{FormatMessages(actual)}"); + + for (int i = 0; i < expected.Count; i++) + { + var exp = expected[i]; + var act = actual[i]; + + Assert.True( + exp.Role == act.Role, + $"Message [{i}]: expected role {exp.Role} but found {act.Role}.\nActual messages:\n{FormatMessages(actual)}"); + + if (exp.TextContains is not null) + { + Assert.Contains(exp.TextContains, act.Text, StringComparison.Ordinal); + } + + if (exp.ContentTypes is not null) + { + AssertContentTypes(act.Contents, exp.ContentTypes, i); + } + } + } + + /// + /// Gets the persisted chat history from the agent's . + /// + /// The agent whose history provider to query. + /// The session to get history for. + /// The list of persisted messages, or an empty list if no provider is available. + public static List GetPersistedHistory(ChatClientAgent agent, AgentSession session) + { + var provider = agent.ChatHistoryProvider as InMemoryChatHistoryProvider; + return provider?.GetMessages(session) ?? []; + } + + /// + /// Formats the contents of a message list as a diagnostic string for test failure messages. + /// + /// The messages to format. + /// A human-readable representation of the messages. + public static string FormatMessages(IEnumerable messages) + { + var sb = new StringBuilder(); + int index = 0; + foreach (var msg in messages) + { + sb.AppendLine($" [{index}] Role={msg.Role}, Text=\"{msg.Text}\", Contents=[{string.Join(", ", msg.Contents.Select(c => c.GetType().Name))}]"); + index++; + } + + return sb.ToString(); + } + + /// + /// A simple mutable reference wrapper for value types, allowing shared state across callbacks. + /// + public sealed class Ref(T value) where T : struct + { + public T Value { get; set; } = value; + } + + /// + /// Asserts that a message's content collection contains the expected content types. + /// + private static void AssertContentTypes(IList contents, Type[] expectedTypes, int messageIndex) + { + Assert.True( + contents.Count >= expectedTypes.Length, + $"Message [{messageIndex}]: expected at least {expectedTypes.Length} content(s) but found {contents.Count}. " + + $"Actual types: [{string.Join(", ", contents.Select(c => c.GetType().Name))}]"); + + foreach (var expectedType in expectedTypes) + { + Assert.True( + contents.Any(c => expectedType.IsInstanceOfType(c)), + $"Message [{messageIndex}]: expected content of type {expectedType.Name} but found [{string.Join(", ", contents.Select(c => c.GetType().Name))}]"); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs index 2b3cfe43e8..7241ca763e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs @@ -379,18 +379,23 @@ public partial class ChatClientAgentTests } /// - /// Verify that RunAsync passes null ChatOptions when using regular AgentRunOptions. + /// Verify that RunAsync passes ChatOptions with null ConversationId when using regular AgentRunOptions. + /// When per-service-call persistence is active (default), the sentinel conversation ID is set on ChatOptions + /// and then stripped by ChatHistoryPersistingChatClient before reaching the inner client. /// [Fact] - public async Task RunAsyncPassesNullChatOptionsWhenUsingRegularAgentRunOptionsAsync() + public async Task RunAsyncPassesChatOptionsWithNullConversationIdWhenUsingRegularAgentRunOptionsAsync() { // Arrange + ChatOptions? capturedOptions = null; Mock mockService = new(); mockService.Setup( s => s.GetResponseAsync( It.IsAny>(), - null, - It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); ChatClientAgent agent = new(mockService.Object); var runOptions = new AgentRunOptions(); @@ -398,13 +403,9 @@ public partial class ChatClientAgentTests // Act await agent.RunAsync([new(ChatRole.User, "test")], options: runOptions); - // Assert - mockService.Verify( - x => x.GetResponseAsync( - It.IsAny>(), - null, - It.IsAny()), - Times.Once); + // Assert — the inner client receives ChatOptions with null ConversationId (sentinel was stripped) + Assert.NotNull(capturedOptions); + Assert.Null(capturedOptions!.ConversationId); } /// diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ApprovalsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ApprovalsTests.cs new file mode 100644 index 0000000000..6300942c9d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ApprovalsTests.cs @@ -0,0 +1,306 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Contains unit tests that verify the end-to-end approval flow behavior of the +/// class with , +/// ensuring that chat history is correctly persisted across multi-turn approval interactions. +/// +public class ChatClientAgent_ApprovalsTests +{ + #region Per-Service-Call Persistence Approval Tests + + /// + /// Verifies that with per-service-call persistence and an approval-required tool, + /// a two-turn approval flow persists the correct final history: + /// Turn 1: user asks → model returns FCC → FICC converts to ToolApprovalRequestContent → returned to caller. + /// Turn 2: caller sends ToolApprovalResponseContent → FICC processes approval, invokes function, calls model again. + /// Final history: [user, assistant(FCC), tool(FRC), assistant(final)]. + /// + [Fact] + public async Task RunAsync_ApprovalRequired_PerServiceCallPersistence_PersistsCorrectHistoryAsync() + { + // Arrange + var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather"); + var approvalTool = new ApprovalRequiredAIFunction(tool); + + var callIndex = new ChatClientAgentTestHelper.Ref(0); + var capturedInputs = new List>(); + var serviceExpectations = new List + { + // Turn 1: model returns a function call (FICC will convert to approval request) + new(new ChatResponse([new(ChatRole.Assistant, + [new FunctionCallContent("call1", "GetWeather", new Dictionary { ["city"] = "Amsterdam" })])])), + // Turn 2: after approval, FICC invokes the function and calls the model again + new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])), + }; + + // Act — Turn 1: initial request + var result1 = await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "What's the weather?")], + serviceCallExpectations: serviceExpectations, + agentOptions: new() + { + ChatOptions = new() { Tools = [approvalTool] }, + PersistChatHistoryAtEndOfRun = false, + }, + callIndex: callIndex, + capturedInputs: capturedInputs); + + // Verify Turn 1 returns exactly one approval request + var approvalRequests = result1.Response.Messages + .SelectMany(m => m.Contents) + .OfType() + .ToList(); + Assert.Single(approvalRequests); + Assert.Equal(1, result1.TotalServiceCalls); + + // Verify service received user message on first call + Assert.Single(capturedInputs); + Assert.Contains(capturedInputs[0], m => m.Role == ChatRole.User && m.Text == "What's the weather?"); + + // Act — Turn 2: send approval response + var approvalResponseMessages = approvalRequests.ConvertAll(req => + new ChatMessage(ChatRole.User, [req.CreateResponse(approved: true)])); + + await ChatClientAgentTestHelper.RunAsync( + inputMessages: approvalResponseMessages, + serviceCallExpectations: serviceExpectations, + existingSession: result1.Session, + existingAgent: result1.Agent, + existingMock: result1.MockService, + callIndex: callIndex, + capturedInputs: capturedInputs, + expectedServiceCallCount: 2, + expectedHistory: + [ + new(ChatRole.User, TextContains: "What's the weather?"), + new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]), + new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]), + new(ChatRole.Assistant, TextContains: "sunny and 22°C"), + ]); + + // Verify second service call received the full conversation (user + FCC + FRC) + Assert.Equal(2, capturedInputs.Count); + Assert.Contains(capturedInputs[1], m => m.Contents.OfType().Any()); + Assert.Contains(capturedInputs[1], m => m.Contents.OfType().Any()); + } + + #endregion + + #region End-of-Run Persistence Approval Tests + + /// + /// Verifies that with end-of-run persistence and an approval-required tool, + /// a two-turn approval flow persists the correct final history. + /// + [Fact] + public async Task RunAsync_ApprovalRequired_EndOfRunPersistence_PersistsCorrectHistoryAsync() + { + // Arrange + var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather"); + var approvalTool = new ApprovalRequiredAIFunction(tool); + + var callIndex = new ChatClientAgentTestHelper.Ref(0); + var capturedInputs = new List>(); + var serviceExpectations = new List + { + new(new ChatResponse([new(ChatRole.Assistant, + [new FunctionCallContent("call1", "GetWeather", new Dictionary { ["city"] = "Amsterdam" })])])), + new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])), + }; + + // Act — Turn 1 + var result1 = await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "What's the weather?")], + serviceCallExpectations: serviceExpectations, + agentOptions: new() + { + ChatOptions = new() { Tools = [approvalTool] }, + PersistChatHistoryAtEndOfRun = true, + }, + callIndex: callIndex, + capturedInputs: capturedInputs); + + var approvalRequests = result1.Response.Messages + .SelectMany(m => m.Contents) + .OfType() + .ToList(); + Assert.Single(approvalRequests); + + // Act — Turn 2 + var approvalResponseMessages = approvalRequests.ConvertAll(req => + new ChatMessage(ChatRole.User, [req.CreateResponse(approved: true)])); + + var result2 = await ChatClientAgentTestHelper.RunAsync( + inputMessages: approvalResponseMessages, + serviceCallExpectations: serviceExpectations, + existingSession: result1.Session, + existingAgent: result1.Agent, + existingMock: result1.MockService, + callIndex: callIndex, + capturedInputs: capturedInputs, + expectedServiceCallCount: 2, + expectedHistory: + [ + // End-of-run persistence retains the approval request from Turn 1 + new(ChatRole.User, TextContains: "What's the weather?"), + new(ChatRole.Assistant, ContentTypes: [typeof(ToolApprovalRequestContent)]), + new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]), + new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]), + new(ChatRole.Assistant, TextContains: "sunny and 22°C"), + ]); + } + + #endregion + + #region Service-Stored History Approval Tests + + /// + /// Verifies that with service-stored history (ConversationId returned) and an approval-required tool, + /// the two-turn approval flow completes without errors and the session gets the ConversationId. + /// + [Fact] + public async Task RunAsync_ApprovalRequired_ServiceStoredHistory_CompletesWithoutErrorAsync() + { + // Arrange + const string ConversationId = "thread-456"; + var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather"); + var approvalTool = new ApprovalRequiredAIFunction(tool); + + var callIndex = new ChatClientAgentTestHelper.Ref(0); + var capturedInputs = new List>(); + var serviceExpectations = new List + { + new(new ChatResponse([new(ChatRole.Assistant, + [new FunctionCallContent("call1", "GetWeather", new Dictionary { ["city"] = "Amsterdam" })])]) + { + ConversationId = ConversationId, + }), + new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")]) + { + ConversationId = ConversationId, + }), + }; + + // Act — Turn 1 + var result1 = await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "What's the weather?")], + serviceCallExpectations: serviceExpectations, + agentOptions: new() + { + ChatOptions = new() { Tools = [approvalTool] }, + PersistChatHistoryAtEndOfRun = false, + }, + callIndex: callIndex, + capturedInputs: capturedInputs); + + var approvalRequests = result1.Response.Messages + .SelectMany(m => m.Contents) + .OfType() + .ToList(); + Assert.Single(approvalRequests); + Assert.Equal(ConversationId, result1.Session.ConversationId); + + // Act — Turn 2 + var approvalResponseMessages = approvalRequests.ConvertAll(req => + new ChatMessage(ChatRole.User, [req.CreateResponse(approved: true)])); + + var result2 = await ChatClientAgentTestHelper.RunAsync( + inputMessages: approvalResponseMessages, + serviceCallExpectations: serviceExpectations, + existingSession: result1.Session, + existingAgent: result1.Agent, + existingMock: result1.MockService, + callIndex: callIndex, + capturedInputs: capturedInputs, + expectedServiceCallCount: 2); + + // Assert — session should retain the ConversationId, response should be correct + Assert.Equal(ConversationId, result2.Session.ConversationId); + Assert.Contains(result2.Response.Messages, m => m.Text == "The weather in Amsterdam is sunny and 22°C."); + } + + #endregion + + #region Approval Rejected Tests + + /// + /// Verifies that when an approval is rejected, the rejection result is persisted in the history + /// and the model receives the rejection information. + /// + [Fact] + public async Task RunAsync_ApprovalRejected_PersistsRejectionInHistoryAsync() + { + // Arrange + var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather"); + var approvalTool = new ApprovalRequiredAIFunction(tool); + + var callIndex = new ChatClientAgentTestHelper.Ref(0); + var capturedInputs = new List>(); + var serviceExpectations = new List + { + // Turn 1: model requests function call + new(new ChatResponse([new(ChatRole.Assistant, + [new FunctionCallContent("call1", "GetWeather", new Dictionary { ["city"] = "Amsterdam" })])])), + // Turn 2: after rejection, model gets the rejection info and responds accordingly + new(new ChatResponse([new(ChatRole.Assistant, "I'm sorry, I cannot check the weather without your approval.")])), + }; + + // Act — Turn 1 + var result1 = await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "What's the weather?")], + serviceCallExpectations: serviceExpectations, + agentOptions: new() + { + ChatOptions = new() { Tools = [approvalTool] }, + PersistChatHistoryAtEndOfRun = false, + }, + callIndex: callIndex, + capturedInputs: capturedInputs); + + var approvalRequests = result1.Response.Messages + .SelectMany(m => m.Contents) + .OfType() + .ToList(); + Assert.Single(approvalRequests); + + // Act — Turn 2: reject the approval + var rejectionMessages = approvalRequests.ConvertAll(req => + new ChatMessage(ChatRole.User, [req.CreateResponse(approved: false, reason: "User declined")])); + + var result2 = await ChatClientAgentTestHelper.RunAsync( + inputMessages: rejectionMessages, + serviceCallExpectations: serviceExpectations, + existingSession: result1.Session, + existingAgent: result1.Agent, + existingMock: result1.MockService, + callIndex: callIndex, + capturedInputs: capturedInputs, + expectedServiceCallCount: 2); + + // Assert — history should contain the rejection result (FRC with rejection) + var history = ChatClientAgentTestHelper.GetPersistedHistory(result2.Agent, result2.Session); + Assert.True( + history.Count >= 3, + $"Expected at least 3 messages in history, got {history.Count}.\n{ChatClientAgentTestHelper.FormatMessages(history)}"); + Assert.Contains(history, m => m.Role == ChatRole.User && m.Text == "What's the weather?"); + Assert.Contains(history, m => m.Contents.OfType().Any( + frc => frc.Result?.ToString()?.Contains("rejected") == true)); + Assert.Contains(history, m => m.Role == ChatRole.Assistant && + m.Text == "I'm sorry, I cannot check the weather without your approval."); + + // Verify the second service call received the rejection FRC + Assert.Equal(2, capturedInputs.Count); + Assert.Contains(capturedInputs[1], m => m.Contents.OfType().Any( + frc => frc.Result?.ToString()?.Contains("rejected") == true)); + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs index cc9b7acb19..3e54dbc06e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs @@ -500,4 +500,158 @@ public class ChatClientAgent_ChatHistoryManagementTests } #endregion + + #region End-to-End Chat History Persistence Tests + + /// + /// Verifies that with per-service-call persistence (default), a simple request/response + /// results in the correct chat history being persisted: [user, assistant]. + /// + [Fact] + public async Task RunAsync_PerServiceCallPersistence_SimpleResponse_PersistsCorrectHistoryAsync() + { + // Arrange & Act & Assert + await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "Hello")], + serviceCallExpectations: + [ + new(new ChatResponse([new(ChatRole.Assistant, "Hi there")])), + ], + agentOptions: new() + { + ChatOptions = new() { Instructions = "Be helpful" }, + PersistChatHistoryAtEndOfRun = false, + }, + expectedServiceCallCount: 1, + expectedHistory: + [ + new(ChatRole.User, TextContains: "Hello"), + new(ChatRole.Assistant, TextContains: "Hi there"), + ]); + } + + /// + /// Verifies that with per-service-call persistence and a function calling loop, + /// the full conversation is persisted: [user, assistant(FCC), tool(FRC), assistant(final)]. + /// + [Fact] + public async Task RunAsync_PerServiceCallPersistence_FunctionCallingLoop_PersistsCorrectHistoryAsync() + { + // Arrange + var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather"); + + // Act & Assert + await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "What's the weather?")], + serviceCallExpectations: + [ + // First call: model requests a function call + new(new ChatResponse([new(ChatRole.Assistant, + [new FunctionCallContent("call1", "GetWeather", new Dictionary { ["city"] = "Amsterdam" })])])), + // Second call: model returns final response after seeing function result + new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])), + ], + agentOptions: new() + { + ChatOptions = new() { Tools = [tool] }, + PersistChatHistoryAtEndOfRun = false, + }, + expectedServiceCallCount: 2, + expectedHistory: + [ + new(ChatRole.User, TextContains: "What's the weather?"), + new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]), + new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]), + new(ChatRole.Assistant, TextContains: "sunny and 22°C"), + ]); + } + + /// + /// Verifies that with end-of-run persistence, a simple request/response + /// results in the correct chat history being persisted: [user, assistant]. + /// + [Fact] + public async Task RunAsync_EndOfRunPersistence_SimpleResponse_PersistsCorrectHistoryAsync() + { + // Arrange & Act & Assert + await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "Hello")], + serviceCallExpectations: + [ + new(new ChatResponse([new(ChatRole.Assistant, "Hi there")])), + ], + agentOptions: new() + { + ChatOptions = new() { Instructions = "Be helpful" }, + PersistChatHistoryAtEndOfRun = true, + }, + expectedServiceCallCount: 1, + expectedHistory: + [ + new(ChatRole.User, TextContains: "Hello"), + new(ChatRole.Assistant, TextContains: "Hi there"), + ]); + } + + /// + /// Verifies that with end-of-run persistence and a function calling loop, + /// the full conversation is persisted: [user, assistant(FCC), tool(FRC), assistant(final)]. + /// + [Fact] + public async Task RunAsync_EndOfRunPersistence_FunctionCallingLoop_PersistsCorrectHistoryAsync() + { + // Arrange + var tool = AIFunctionFactory.Create(() => "Sunny, 22°C", "GetWeather", "Gets the weather"); + + // Act & Assert + await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "What's the weather?")], + serviceCallExpectations: + [ + new(new ChatResponse([new(ChatRole.Assistant, + [new FunctionCallContent("call1", "GetWeather", new Dictionary { ["city"] = "Amsterdam" })])])), + new(new ChatResponse([new(ChatRole.Assistant, "The weather in Amsterdam is sunny and 22°C.")])), + ], + agentOptions: new() + { + ChatOptions = new() { Tools = [tool] }, + PersistChatHistoryAtEndOfRun = true, + }, + expectedServiceCallCount: 2, + expectedHistory: + [ + new(ChatRole.User, TextContains: "What's the weather?"), + new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]), + new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]), + new(ChatRole.Assistant, TextContains: "sunny and 22°C"), + ]); + } + + /// + /// Verifies that when the service returns a ConversationId (service-stored history), + /// the session gets the ConversationId and no errors occur during the run. + /// + [Fact] + public async Task RunAsync_ServiceStoredHistory_SetsConversationIdAndCompletesWithoutErrorAsync() + { + // Arrange & Act + var result = await ChatClientAgentTestHelper.RunAsync( + inputMessages: [new(ChatRole.User, "Hello")], + serviceCallExpectations: + [ + new(new ChatResponse([new(ChatRole.Assistant, "Hi there")]) { ConversationId = "thread-123" }), + ], + agentOptions: new() + { + ChatOptions = new() { Instructions = "Be helpful" }, + PersistChatHistoryAtEndOfRun = false, + }, + expectedServiceCallCount: 1); + + // Assert — session should have the conversation id from the service + Assert.Equal("thread-123", result.Session.ConversationId); + Assert.Contains(result.Response.Messages, m => m.Text == "Hi there"); + } + + #endregion } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatOptionsMergingTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatOptionsMergingTests.cs index 6dda0f0278..28d38ea36a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatOptionsMergingTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatOptionsMergingTests.cs @@ -176,10 +176,12 @@ public class ChatClientAgent_ChatOptionsMergingTests } /// - /// Verify that ChatOptions merging returns null when both agent and request have no ChatOptions. + /// Verify that ChatOptions merging returns a non-null ChatOptions instance with null ConversationId + /// when both agent and request have no ChatOptions. The sentinel conversation ID is set for + /// per-service-call persistence and stripped before reaching the inner client. /// [Fact] - public async Task ChatOptionsMergingReturnsNullWhenBothAgentAndRequestHaveNoneAsync() + public async Task ChatOptionsMergingReturnsChatOptionsWithNullConversationIdWhenBothAgentAndRequestHaveNoneAsync() { // Arrange Mock mockService = new(); @@ -189,7 +191,7 @@ public class ChatClientAgent_ChatOptionsMergingTests It.IsAny>(), It.IsAny(), It.IsAny())) - .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => + .Callback, ChatOptions?, CancellationToken>((msgs, opts, ct) => capturedChatOptions = opts) .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); @@ -199,8 +201,9 @@ public class ChatClientAgent_ChatOptionsMergingTests // Act await agent.RunAsync(messages); - // Assert - Assert.Null(capturedChatOptions); + // Assert — ChatOptions is non-null because the sentinel was set, but ConversationId is null (stripped) + Assert.NotNull(capturedChatOptions); + Assert.Null(capturedChatOptions!.ConversationId); } /// diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatHistoryPersistingChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatHistoryPersistingChatClientTests.cs index 459859224f..e7f91ab5d7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatHistoryPersistingChatClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatHistoryPersistingChatClientTests.cs @@ -763,4 +763,171 @@ public class ChatHistoryPersistingChatClientTests await Task.CompletedTask; } + + /// + /// Verifies that when per-service-call persistence is active and no real conversation ID exists, + /// sets the + /// sentinel on the chat options and strips it before + /// forwarding to the inner client. + /// + [Fact] + public async Task RunAsync_SetsAndStripsSentinelConversationId_WhenPerServiceCallPersistenceActiveAsync() + { + // Arrange + ChatOptions? capturedOptions = null; + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatOptions = new() { Instructions = "test" }, + PersistChatHistoryAtEndOfRun = false, + }); + + // Act + await agent.RunAsync([new(ChatRole.User, "test")]); + + // Assert — the inner client should NOT see the sentinel conversation ID + Assert.NotNull(capturedOptions); + Assert.Null(capturedOptions!.ConversationId); + } + + /// + /// Verifies that the sentinel is NOT set when end-of-run persistence is enabled + /// (mark-only mode), since the issue only applies to per-service-call persistence. + /// + [Fact] + public async Task RunAsync_DoesNotSetSentinel_WhenEndOfRunPersistenceEnabledAsync() + { + // Arrange + ChatOptions? capturedOptions = null; + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatOptions = new() { Instructions = "test" }, + PersistChatHistoryAtEndOfRun = true, + }); + + // Act + await agent.RunAsync([new(ChatRole.User, "test")]); + + // Assert — the inner client should see options but NOT the sentinel conversation ID + Assert.NotNull(capturedOptions); + Assert.Null(capturedOptions!.ConversationId); + } + + /// + /// Verifies that the sentinel is NOT set when a real conversation ID is already present + /// on the session (indicating server-side history management). + /// + [Fact] + public async Task RunAsync_DoesNotSetSentinel_WhenRealConversationIdExistsAsync() + { + // Arrange + const string RealConversationId = "real-conv-123"; + ChatOptions? capturedOptions = null; + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) + { + ConversationId = RealConversationId, + }); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + PersistChatHistoryAtEndOfRun = false, + }); + + // Create a session with a real conversation ID. + var session = await agent.CreateSessionAsync(RealConversationId); + + // Act + await agent.RunAsync([new(ChatRole.User, "test")], session); + + // Assert — the inner client should see the real conversation ID, not the sentinel + Assert.NotNull(capturedOptions); + Assert.Equal(RealConversationId, capturedOptions!.ConversationId); + } + + /// + /// Verifies that the sentinel is set and stripped correctly in the streaming path. + /// + [Fact] + public async Task RunStreamingAsync_SetsAndStripsSentinelConversationId_WhenPerServiceCallPersistenceActiveAsync() + { + // Arrange + ChatOptions? capturedOptions = null; + Mock mockService = new(); + mockService.Setup( + s => s.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions?, CancellationToken>((_, opts, _) => capturedOptions = opts) + .Returns(CreateAsyncEnumerableAsync(new ChatResponseUpdate(role: ChatRole.Assistant, content: "response"))); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatOptions = new() { Instructions = "test" }, + PersistChatHistoryAtEndOfRun = false, + }); + + // Act + await foreach (var _ in agent.RunStreamingAsync([new(ChatRole.User, "test")])) + { + // Consume the stream. + } + + // Assert — the inner client should NOT see the sentinel conversation ID + Assert.NotNull(capturedOptions); + Assert.Null(capturedOptions!.ConversationId); + } + + /// + /// Verifies that the session's conversation ID is NOT set to the sentinel after the run. + /// The sentinel should only exist transiently on the ChatOptions for the pipeline. + /// + [Fact] + public async Task RunAsync_SentinelDoesNotLeakToSession_WhenPerServiceCallPersistenceActiveAsync() + { + // Arrange + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + PersistChatHistoryAtEndOfRun = false, + }); + + // Act + var session = await agent.CreateSessionAsync() as ChatClientAgentSession; + await agent.RunAsync([new(ChatRole.User, "test")], session); + + // Assert — session should NOT have the sentinel conversation ID + Assert.Null(session!.ConversationId); + } } From 9bfa593ae7bea160aef6885962611590423f59a9 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Fri, 27 Mar 2026 02:52:07 +0900 Subject: [PATCH 15/22] Python: Move ag_ui_workflow_handoff demo from demos/ to 05-end-to-end/ (#4900) * Move ag_ui_workflow_handoff demo to 05-end-to-end (#4895) Move the AG-UI workflow handoff demo from python/samples/demos/ to python/samples/05-end-to-end/ to follow the current folder structure convention. Update README paths accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix review feedback: remove build artifacts, fix README paths (#4895) - Add .gitignore to frontend/ to exclude *.tsbuildinfo, vite.config.js, and vite.config.d.ts build artifacts from version control - Remove the 4 tracked build artifact files from the tree - Fix step 2 cd path in README to be relative after 'cd python' Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Clarify working directory context in README Step 2 (#4895) Step 2 uses a python/-relative path (samples/...) which assumes the user is still in the python/ directory from Step 1. Add a brief note making this explicit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ag_ui_workflow_handoff/README.md | 8 +++++--- .../ag_ui_workflow_handoff/backend/server.py | 0 .../ag_ui_workflow_handoff/frontend/.gitignore | 7 +++++++ .../ag_ui_workflow_handoff/frontend/index.html | 0 .../ag_ui_workflow_handoff/frontend/package-lock.json | 0 .../ag_ui_workflow_handoff/frontend/package.json | 0 .../ag_ui_workflow_handoff/frontend/src/App.tsx | 0 .../ag_ui_workflow_handoff/frontend/src/main.tsx | 0 .../ag_ui_workflow_handoff/frontend/src/styles.css | 0 .../ag_ui_workflow_handoff/frontend/src/vite-env.d.ts | 0 .../ag_ui_workflow_handoff/frontend/tsconfig.json | 0 .../frontend/tsconfig.node.json | 0 .../ag_ui_workflow_handoff/frontend/vite.config.ts | 0 .../frontend/tsconfig.node.tsbuildinfo | 1 - .../frontend/tsconfig.tsbuildinfo | 1 - .../ag_ui_workflow_handoff/frontend/vite.config.d.ts | 2 -- .../ag_ui_workflow_handoff/frontend/vite.config.js | 11 ----------- 17 files changed, 12 insertions(+), 18 deletions(-) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/README.md (93%) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/backend/server.py (100%) create mode 100644 python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/.gitignore rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/frontend/index.html (100%) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/frontend/package-lock.json (100%) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/frontend/package.json (100%) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/frontend/src/App.tsx (100%) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/frontend/src/main.tsx (100%) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/frontend/src/styles.css (100%) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/frontend/src/vite-env.d.ts (100%) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/frontend/tsconfig.json (100%) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/frontend/tsconfig.node.json (100%) rename python/samples/{demos => 05-end-to-end}/ag_ui_workflow_handoff/frontend/vite.config.ts (100%) delete mode 100644 python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.node.tsbuildinfo delete mode 100644 python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.tsbuildinfo delete mode 100644 python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.d.ts delete mode 100644 python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.js diff --git a/python/samples/demos/ag_ui_workflow_handoff/README.md b/python/samples/05-end-to-end/ag_ui_workflow_handoff/README.md similarity index 93% rename from python/samples/demos/ag_ui_workflow_handoff/README.md rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/README.md index bd9a6b6a5f..51e1c9fc1c 100644 --- a/python/samples/demos/ag_ui_workflow_handoff/README.md +++ b/python/samples/05-end-to-end/ag_ui_workflow_handoff/README.md @@ -35,9 +35,9 @@ The backend uses Azure OpenAI responses and supports intent-driven, non-linear h From the Python repo root: ```bash -cd /Users/evmattso/git/agent-framework/python +cd python uv sync -uv run python samples/demos/ag_ui_workflow_handoff/backend/server.py +uv run python samples/05-end-to-end/ag_ui_workflow_handoff/backend/server.py ``` Backend default URL: @@ -47,8 +47,10 @@ Backend default URL: ## 2) Install Frontend Packages (npm) +From the `python/` directory (where Step 1 left you): + ```bash -cd /Users/evmattso/git/agent-framework/python/samples/demos/ag_ui_workflow_handoff/frontend +cd samples/05-end-to-end/ag_ui_workflow_handoff/frontend npm install ``` diff --git a/python/samples/demos/ag_ui_workflow_handoff/backend/server.py b/python/samples/05-end-to-end/ag_ui_workflow_handoff/backend/server.py similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/backend/server.py rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/backend/server.py diff --git a/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/.gitignore b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/.gitignore new file mode 100644 index 0000000000..16c69217c0 --- /dev/null +++ b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/.gitignore @@ -0,0 +1,7 @@ +# dependencies +/node_modules + +# build artifacts +*.tsbuildinfo +vite.config.js +vite.config.d.ts diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/index.html b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/index.html similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/index.html rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/index.html diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/package-lock.json b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package-lock.json similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/package-lock.json rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package-lock.json diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/package.json b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package.json similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/package.json rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package.json diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/src/App.tsx b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/App.tsx similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/src/App.tsx rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/App.tsx diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/src/main.tsx b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/main.tsx similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/src/main.tsx rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/main.tsx diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/src/styles.css b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/styles.css similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/src/styles.css rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/styles.css diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/src/vite-env.d.ts b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/vite-env.d.ts similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/src/vite-env.d.ts rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/src/vite-env.d.ts diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.json b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/tsconfig.json similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.json rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/tsconfig.json diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.node.json b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/tsconfig.node.json similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.node.json rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/tsconfig.node.json diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.ts b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/vite.config.ts similarity index 100% rename from python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.ts rename to python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/vite.config.ts diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.node.tsbuildinfo b/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.node.tsbuildinfo deleted file mode 100644 index 9c052ccd41..0000000000 --- a/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.node.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/@types/estree/index.d.ts","./node_modules/rollup/dist/rollup.d.ts","./node_modules/rollup/dist/parseast.d.ts","./node_modules/vite/types/hmrpayload.d.ts","./node_modules/vite/types/customevent.d.ts","./node_modules/vite/types/hot.d.ts","./node_modules/vite/dist/node/types.d-agj9qkwt.d.ts","./node_modules/esbuild/lib/main.d.ts","./node_modules/source-map-js/source-map.d.ts","./node_modules/postcss/lib/previous-map.d.ts","./node_modules/postcss/lib/input.d.ts","./node_modules/postcss/lib/css-syntax-error.d.ts","./node_modules/postcss/lib/declaration.d.ts","./node_modules/postcss/lib/root.d.ts","./node_modules/postcss/lib/warning.d.ts","./node_modules/postcss/lib/lazy-result.d.ts","./node_modules/postcss/lib/no-work-result.d.ts","./node_modules/postcss/lib/processor.d.ts","./node_modules/postcss/lib/result.d.ts","./node_modules/postcss/lib/document.d.ts","./node_modules/postcss/lib/rule.d.ts","./node_modules/postcss/lib/node.d.ts","./node_modules/postcss/lib/comment.d.ts","./node_modules/postcss/lib/container.d.ts","./node_modules/postcss/lib/at-rule.d.ts","./node_modules/postcss/lib/list.d.ts","./node_modules/postcss/lib/postcss.d.ts","./node_modules/postcss/lib/postcss.d.mts","./node_modules/vite/dist/node/runtime.d.ts","./node_modules/vite/types/importglob.d.ts","./node_modules/vite/types/metadata.d.ts","./node_modules/vite/dist/node/index.d.ts","./node_modules/@babel/types/lib/index.d.ts","./node_modules/@types/babel__generator/index.d.ts","./node_modules/@babel/parser/typings/babel-parser.d.ts","./node_modules/@types/babel__template/index.d.ts","./node_modules/@types/babel__traverse/index.d.ts","./node_modules/@types/babel__core/index.d.ts","./node_modules/@vitejs/plugin-react/dist/index.d.ts","./vite.config.ts"],"fileIdsList":[[78],[78,79,80,81,82],[78,80],[77,83],[69],[67,69],[58,66,67,68,70,72],[56],[59,64,69,72],[55,72],[59,60,63,64,65,72],[59,60,61,63,64,72],[56,57,58,59,60,64,65,66,68,69,70,72],[72],[54,56,57,58,59,60,61,63,64,65,66,67,68,69,70,71],[54,72],[59,61,62,64,65,72],[63,72],[64,65,69,72],[57,67],[47,76],[46,47],[47,48,49,50,51,52,53,73,74,75,76],[49,50,51,52],[49,50,51],[49],[50],[47],[77,84]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"151ff381ef9ff8da2da9b9663ebf657eac35c4c9a19183420c05728f31a6761d","impliedFormat":1},{"version":"ee70b8037ecdf0de6c04f35277f253663a536d7e38f1539d270e4e916d225a3f","affectsGlobalScope":true,"impliedFormat":1},{"version":"a660aa95476042d3fdcc1343cf6bb8fdf24772d31712b1db321c5a4dcc325434","impliedFormat":1},{"version":"282f98006ed7fa9bb2cd9bdbe2524595cfc4bcd58a0bb3232e4519f2138df811","impliedFormat":1},{"version":"6222e987b58abfe92597e1273ad7233626285bc2d78409d4a7b113d81a83496b","impliedFormat":1},{"version":"cbe726263ae9a7bf32352380f7e8ab66ee25b3457137e316929269c19e18a2be","impliedFormat":1},{"version":"8b96046bf5fb0a815cba6b0880d9f97b7f3a93cf187e8dcfe8e2792e97f38f87","impliedFormat":99},{"version":"bacf2c84cf448b2cd02c717ad46c3d7fd530e0c91282888c923ad64810a4d511","affectsGlobalScope":true,"impliedFormat":1},{"version":"402e5c534fb2b85fa771170595db3ac0dd532112c8fa44fc23f233bc6967488b","impliedFormat":1},{"version":"8885cf05f3e2abf117590bbb951dcf6359e3e5ac462af1c901cfd24c6a6472e2","impliedFormat":1},{"version":"333caa2bfff7f06017f114de738050dd99a765c7eb16571c6d25a38c0d5365dc","impliedFormat":1},{"version":"e61df3640a38d535fd4bc9f4a53aef17c296b58dc4b6394fd576b808dd2fe5e6","impliedFormat":1},{"version":"459920181700cec8cbdf2a5faca127f3f17fd8dd9d9e577ed3f5f3af5d12a2e4","impliedFormat":1},{"version":"4719c209b9c00b579553859407a7e5dcfaa1c472994bd62aa5dd3cc0757eb077","impliedFormat":1},{"version":"7ec359bbc29b69d4063fe7dad0baaf35f1856f914db16b3f4f6e3e1bca4099fa","impliedFormat":1},{"version":"70790a7f0040993ca66ab8a07a059a0f8256e7bb57d968ae945f696cbff4ac7a","impliedFormat":1},{"version":"d1b9a81e99a0050ca7f2d98d7eedc6cda768f0eb9fa90b602e7107433e64c04c","impliedFormat":1},{"version":"a022503e75d6953d0e82c2c564508a5c7f8556fad5d7f971372d2d40479e4034","impliedFormat":1},{"version":"b215c4f0096f108020f666ffcc1f072c81e9f2f95464e894a5d5f34c5ea2a8b1","impliedFormat":1},{"version":"644491cde678bd462bb922c1d0cfab8f17d626b195ccb7f008612dc31f445d2d","impliedFormat":1},{"version":"dfe54dab1fa4961a6bcfba68c4ca955f8b5bbeb5f2ab3c915aa7adaa2eabc03a","impliedFormat":1},{"version":"1251d53755b03cde02466064260bb88fd83c30006a46395b7d9167340bc59b73","impliedFormat":1},{"version":"47865c5e695a382a916b1eedda1b6523145426e48a2eae4647e96b3b5e52024f","impliedFormat":1},{"version":"4cdf27e29feae6c7826cdd5c91751cc35559125e8304f9e7aed8faef97dcf572","impliedFormat":1},{"version":"331b8f71bfae1df25d564f5ea9ee65a0d847c4a94baa45925b6f38c55c7039bf","impliedFormat":1},{"version":"2a771d907aebf9391ac1f50e4ad37952943515eeea0dcc7e78aa08f508294668","impliedFormat":1},{"version":"0146fd6262c3fd3da51cb0254bb6b9a4e42931eb2f56329edd4c199cb9aaf804","impliedFormat":1},{"version":"183f480885db5caa5a8acb833c2be04f98056bdcc5fb29e969ff86e07efe57ab","impliedFormat":99},{"version":"82e687ebd99518bc63ea04b0c3810fb6e50aa6942decd0ca6f7a56d9b9a212a6","impliedFormat":99},{"version":"7f698624bbbb060ece7c0e51b7236520ebada74b747d7523c7df376453ed6fea","impliedFormat":1},{"version":"8f07f2b6514744ac96e51d7cb8518c0f4de319471237ea10cf688b8d0e9d0225","impliedFormat":1},{"version":"257b83faa134d971c738a6b9e4c47e59bb7b23274719d92197580dd662bfafc3","impliedFormat":99},{"version":"556ccd493ec36c7d7cb130d51be66e147b91cc1415be383d71da0f1e49f742a9","impliedFormat":1},{"version":"b6d03c9cfe2cf0ba4c673c209fcd7c46c815b2619fd2aad59fc4229aaef2ed43","impliedFormat":1},{"version":"95aba78013d782537cc5e23868e736bec5d377b918990e28ed56110e3ae8b958","impliedFormat":1},{"version":"670a76db379b27c8ff42f1ba927828a22862e2ab0b0908e38b671f0e912cc5ed","impliedFormat":1},{"version":"13b77ab19ef7aadd86a1e54f2f08ea23a6d74e102909e3c00d31f231ed040f62","impliedFormat":1},{"version":"069bebfee29864e3955378107e243508b163e77ab10de6a5ee03ae06939f0bb9","impliedFormat":1},{"version":"26e0ffceb2198feb1ef460d5d14111c69ad07d44c5a67fd4bfeb74c969aa9afb","impliedFormat":99},{"version":"2448a94bdacc4085b4fd26ccb7c3f323d04a220af29a24b61703903730b68984","signature":"4b96dd19fd2949d28ce80e913412b0026dc421e5bf6c31d87c7b5eb11b5753b4"}],"root":[85],"options":{"allowSyntheticDefaultImports":true,"composite":true,"module":99,"skipLibCheck":true,"target":7},"referencedMap":[[80,1],[83,2],[79,1],[81,3],[82,1],[84,4],[70,5],[68,6],[69,7],[57,8],[58,6],[65,9],[56,10],[61,11],[62,12],[67,13],[73,14],[72,15],[55,16],[63,17],[64,18],[59,19],[66,5],[60,20],[48,21],[47,22],[77,23],[74,24],[52,25],[50,26],[51,27],[76,28],[85,29]],"semanticDiagnosticsPerFile":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85],"latestChangedDtsFile":"./vite.config.d.ts","version":"5.9.3"} \ No newline at end of file diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.tsbuildinfo b/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.tsbuildinfo deleted file mode 100644 index 68fc7fc564..0000000000 --- a/python/samples/demos/ag_ui_workflow_handoff/frontend/tsconfig.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.d.ts b/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.d.ts deleted file mode 100644 index 340562aff1..0000000000 --- a/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const _default: import("vite").UserConfig; -export default _default; diff --git a/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.js b/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.js deleted file mode 100644 index 96a3b3875f..0000000000 --- a/python/samples/demos/ag_ui_workflow_handoff/frontend/vite.config.js +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; -export default defineConfig({ - plugins: [react()], - server: { - host: "127.0.0.1", - port: 5173, - }, -}); From 5530bc536bc6d163625980f0245e35b5a2a4d2f0 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 26 Mar 2026 16:17:57 -0400 Subject: [PATCH 16/22] .NET: feat: Implement return-to-previous routing in handoff workflow (#4356) * feat: Implement return-to-previous routing in handoff workflow - Also obsoletes HandoffsWorkflowBuilder => HandoffWorkflowBuilder (no "s") * refactor: Remove instance-shared current agent tracking in handoffs Because the tracker was instance-shared between the start and end executors, it would be shared between all sessions, resulting in incorrect behaviour. The corect way to do this is to keep the data in a shared executor scope, which is per-session. * fix: Fix test logic for Handoff to correctly use checkpointing for multiturn --- .../AgentWorkflowBuilder.cs | 4 +- .../HandoffsWorkflowBuilder.cs | 86 +++++-- .../Specialized/HandoffAgentExecutor.cs | 12 +- .../Specialized/HandoffState.cs | 3 +- .../Specialized/HandoffsEndExecutor.cs | 19 +- .../Specialized/HandoffsStartExecutor.cs | 28 ++- .../AgentWorkflowBuilderTests.cs | 223 ++++++++++++++++-- .../Sample/12_HandOff_HostAsAgent.cs | 4 +- .../WorkflowHostSmokeTests.cs | 2 +- 9 files changed, 323 insertions(+), 58 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs index 501c7df230..22ee1b48eb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs @@ -145,7 +145,7 @@ public static partial class AgentWorkflowBuilder return builder.Build(); } - /// Creates a new using as the starting agent in the workflow. + /// Creates a new using as the starting agent in the workflow. /// The agent that will receive inputs provided to the workflow. /// The builder for creating a workflow based on handoffs. /// @@ -154,7 +154,7 @@ public static partial class AgentWorkflowBuilder /// The must be capable of understanding those provided. If the agent /// ignores the tools or is otherwise unable to advertize them to the underlying provider, handoffs will not occur. /// - public static HandoffsWorkflowBuilder CreateHandoffBuilderWith(AIAgent initialAgent) + public static HandoffWorkflowBuilder CreateHandoffBuilderWith(AIAgent initialAgent) { Throw.IfNull(initialAgent); return new(initialAgent); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs index ccb993b188..8e5cee7ed5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.Linq; using Microsoft.Agents.AI.Workflows.Specialized; @@ -8,10 +9,21 @@ using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows; +/// +[Obsolete("Prefer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed in a future release before GA.")] +public sealed class HandoffsWorkflowBuilder(AIAgent initialAgent) : HandoffWorkflowBuilderCore(initialAgent) +{ +} + +/// +public sealed class HandoffWorkflowBuilder(AIAgent initialAgent) : HandoffWorkflowBuilderCore(initialAgent) +{ +} + /// /// Provides a builder for specifying the handoff relationships between agents and building the resulting workflow. /// -public sealed class HandoffsWorkflowBuilder +public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkflowBuilderCore { /// /// The prefix for function calls that trigger handoffs to other agents; the full name is then `{FunctionPrefix}<agent_id>`, @@ -26,12 +38,13 @@ public sealed class HandoffsWorkflowBuilder private bool _emitAgentResponseEvents; private bool _emitAgentResponseUpdateEvents; private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly; + private bool _returnToPrevious; /// /// Initializes a new instance of the class with no handoff relationships. /// /// The first agent to be invoked (prior to any handoff). - internal HandoffsWorkflowBuilder(AIAgent initialAgent) + internal HandoffWorkflowBuilderCore(AIAgent initialAgent) { this._initialAgent = initialAgent; this._allAgents.Add(initialAgent); @@ -63,10 +76,10 @@ public sealed class HandoffsWorkflowBuilder /// constant. /// /// The instructions to provide, or to restore the default instructions. - public HandoffsWorkflowBuilder WithHandoffInstructions(string? instructions) + public TBuilder WithHandoffInstructions(string? instructions) { this.HandoffInstructions = instructions ?? DefaultHandoffInstructions; - return this; + return (TBuilder)this; } /// @@ -75,10 +88,10 @@ public sealed class HandoffsWorkflowBuilder /// /// /// - public HandoffsWorkflowBuilder EmitAgentResponseUpdateEvents(bool emitAgentResponseUpdateEvents = true) + public TBuilder EmitAgentResponseUpdateEvents(bool emitAgentResponseUpdateEvents = true) { this._emitAgentResponseUpdateEvents = emitAgentResponseUpdateEvents; - return this; + return (TBuilder)this; } /// @@ -86,10 +99,10 @@ public sealed class HandoffsWorkflowBuilder /// /// /// - public HandoffsWorkflowBuilder EmitAgentResponseEvents(bool emitAgentResponseEvents = true) + public TBuilder EmitAgentResponseEvents(bool emitAgentResponseEvents = true) { this._emitAgentResponseEvents = emitAgentResponseEvents; - return this; + return (TBuilder)this; } /// @@ -97,10 +110,21 @@ public sealed class HandoffsWorkflowBuilder /// s flowing through the handoff workflow. Defaults to . /// /// The filtering behavior to apply. - public HandoffsWorkflowBuilder WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior behavior) + public TBuilder WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior behavior) { this._toolCallFilteringBehavior = behavior; - return this; + return (TBuilder)this; + } + + /// + /// Configures the workflow so that subsequent user turns route directly back to the specialist agent + /// that handled the previous turn, rather than always routing through the initial (coordinator) agent. + /// + /// The updated instance. + public TBuilder EnableReturnToPrevious() + { + this._returnToPrevious = true; + return (TBuilder)this; } /// @@ -110,7 +134,7 @@ public sealed class HandoffsWorkflowBuilder /// The target agents to add as handoff targets for the source agent. /// The updated instance. /// The handoff reason for each target in is derived from that agent's description or name. - public HandoffsWorkflowBuilder WithHandoffs(AIAgent from, IEnumerable to) + public TBuilder WithHandoffs(AIAgent from, IEnumerable to) { Throw.IfNull(from); Throw.IfNull(to); @@ -125,7 +149,7 @@ public sealed class HandoffsWorkflowBuilder this.WithHandoff(from, target); } - return this; + return (TBuilder)this; } /// @@ -138,7 +162,7 @@ public sealed class HandoffsWorkflowBuilder /// If , the reason is derived from 's description or name. /// /// The updated instance. - public HandoffsWorkflowBuilder WithHandoffs(IEnumerable from, AIAgent to, string? handoffReason = null) + public TBuilder WithHandoffs(IEnumerable from, AIAgent to, string? handoffReason = null) { Throw.IfNull(from); Throw.IfNull(to); @@ -153,7 +177,7 @@ public sealed class HandoffsWorkflowBuilder this.WithHandoff(source, to, handoffReason); } - return this; + return (TBuilder)this; } /// @@ -166,7 +190,7 @@ public sealed class HandoffsWorkflowBuilder /// If , the reason is derived from 's description or name. /// /// The updated instance. - public HandoffsWorkflowBuilder WithHandoff(AIAgent from, AIAgent to, string? handoffReason = null) + public TBuilder WithHandoff(AIAgent from, AIAgent to, string? handoffReason = null) { Throw.IfNull(from); Throw.IfNull(to); @@ -196,7 +220,7 @@ public sealed class HandoffsWorkflowBuilder Throw.InvalidOperationException($"A handoff from agent '{from.Name ?? from.Id}' to agent '{to.Name ?? to.Id}' has already been registered."); } - return this; + return (TBuilder)this; } /// @@ -206,8 +230,8 @@ public sealed class HandoffsWorkflowBuilder /// The workflow built based on the handoffs in the builder. public Workflow Build() { - HandoffsStartExecutor start = new(); - HandoffsEndExecutor end = new(); + HandoffsStartExecutor start = new(this._returnToPrevious); + HandoffsEndExecutor end = new(this._returnToPrevious); WorkflowBuilder builder = new(start); HandoffAgentExecutorOptions options = new(this.HandoffInstructions, @@ -215,11 +239,31 @@ public sealed class HandoffsWorkflowBuilder this._emitAgentResponseUpdateEvents, this._toolCallFilteringBehavior); - // Create an AgentExecutor for each again. + // Create an AgentExecutor for each agent. Dictionary executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, options)); - // Connect the start executor to the initial agent. - builder.AddEdge(start, executors[this._initialAgent.Id]); + // Connect the start executor to the initial agent (or use dynamic routing when ReturnToPrevious is enabled). + if (this._returnToPrevious) + { + string initialAgentId = this._initialAgent.Id; + builder.AddSwitch(start, sb => + { + foreach (var agent in this._allAgents) + { + if (agent.Id != initialAgentId) + { + string agentId = agent.Id; + sb.AddCase(state => state?.CurrentAgentId == agentId, executors[agentId]); + } + } + + sb.WithDefault(executors[initialAgentId]); + }); + } + else + { + builder.AddEdge(start, executors[this._initialAgent.Id]); + } // Initialize each executor with its handoff targets to the other executors. foreach (var agent in this._allAgents) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs index 21519e07ee..98205a40c6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs @@ -42,7 +42,7 @@ internal sealed class HandoffMessagesFilter internal static bool IsHandoffFunctionName(string name) { - return name.StartsWith(HandoffsWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal); + return name.StartsWith(HandoffWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal); } public IEnumerable FilterMessages(List messages) @@ -173,6 +173,7 @@ internal sealed class HandoffAgentExecutor( private readonly AIAgent _agent = agent; private readonly HashSet _handoffFunctionNames = []; + private readonly Dictionary _handoffFunctionToAgentId = []; private ChatClientAgentRunOptions? _agentOptions; public void Initialize( @@ -199,9 +200,10 @@ internal sealed class HandoffAgentExecutor( foreach (HandoffTarget handoff in handoffs) { index++; - var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffsWorkflowBuilder.FunctionPrefix}{index}", handoff.Reason, s_handoffSchema); + var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffWorkflowBuilder.FunctionPrefix}{index}", handoff.Reason, s_handoffSchema); this._handoffFunctionNames.Add(handoffFunc.Name); + this._handoffFunctionToAgentId[handoffFunc.Name] = handoff.Target.Id; this._agentOptions.ChatOptions.Tools.Add(handoffFunc); @@ -267,7 +269,11 @@ internal sealed class HandoffAgentExecutor( roleChanges.ResetUserToAssistantForChangedRoles(); - return new(message.TurnToken, requestedHandoff, allMessages); + string currentAgentId = requestedHandoff is not null && this._handoffFunctionToAgentId.TryGetValue(requestedHandoff, out string? targetAgentId) + ? targetAgentId + : this._agent.Id; + + return new(message.TurnToken, requestedHandoff, allMessages, currentAgentId); async Task AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken) { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs index cc4d87d21a..56e2fef9df 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs @@ -8,4 +8,5 @@ namespace Microsoft.Agents.AI.Workflows.Specialized; internal sealed record class HandoffState( TurnToken TurnToken, string? InvokedHandoff, - List Messages); + List Messages, + string? CurrentAgentId = null); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs index 69f81376be..4a43c00a72 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs @@ -1,20 +1,35 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.Specialized; /// Executor used at the end of a handoff workflow to raise a final completed event. -internal sealed class HandoffsEndExecutor() : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor +internal sealed class HandoffsEndExecutor(bool returnToPrevious) : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor { public const string ExecutorId = "HandoffEnd"; protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) => protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler((handoff, context, cancellationToken) => - context.YieldOutputAsync(handoff.Messages, cancellationToken))) + this.HandleAsync(handoff, context, cancellationToken))) .YieldsOutput>(); + private async ValueTask HandleAsync(HandoffState handoff, IWorkflowContext context, CancellationToken cancellationToken) + { + if (returnToPrevious) + { + await context.QueueStateUpdateAsync(HandoffConstants.CurrentAgentTrackerKey, + handoff.CurrentAgentId, + HandoffConstants.CurrentAgentTrackerScope, + cancellationToken) + .ConfigureAwait(false); + } + + await context.YieldOutputAsync(handoff.Messages, cancellationToken).ConfigureAwait(false); + } + public ValueTask ResetAsync() => default; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs index 9039e86f5b..87c3b4566b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs @@ -7,8 +7,14 @@ using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.Specialized; +internal static class HandoffConstants +{ + internal const string CurrentAgentTrackerKey = "LastAgentId"; + internal const string CurrentAgentTrackerScope = "HandoffOrchestration"; +} + /// Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token. -internal sealed class HandoffsStartExecutor() : ChatProtocolExecutor(ExecutorId, DefaultOptions, declareCrossRunShareable: true), IResettableExecutor +internal sealed class HandoffsStartExecutor(bool returnToPrevious) : ChatProtocolExecutor(ExecutorId, DefaultOptions, declareCrossRunShareable: true), IResettableExecutor { internal const string ExecutorId = "HandoffStart"; @@ -22,7 +28,25 @@ internal sealed class HandoffsStartExecutor() : ChatProtocolExecutor(ExecutorId, base.ConfigureProtocol(protocolBuilder).SendsMessage(); protected override ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default) - => context.SendMessageAsync(new HandoffState(new(emitEvents), null, messages), cancellationToken: cancellationToken); + { + if (returnToPrevious) + { + return context.InvokeWithStateAsync( + async (string? currentAgentId, IWorkflowContext context, CancellationToken cancellationToken) => + { + HandoffState handoffState = new(new(emitEvents), null, messages, currentAgentId); + await context.SendMessageAsync(handoffState, cancellationToken).ConfigureAwait(false); + + return currentAgentId; + }, + HandoffConstants.CurrentAgentTrackerKey, + HandoffConstants.CurrentAgentTrackerScope, + cancellationToken); + } + + HandoffState handoff = new(new(emitEvents), null, messages); + return context.SendMessageAsync(handoff, cancellationToken); + } public new ValueTask ResetAsync() => base.ResetAsync(); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs index 77d8d0a88d..7f06145a8e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs @@ -147,7 +147,7 @@ public class AgentWorkflowBuilderTests for (int iter = 0; iter < 3; iter++) { const string UserInput = "abc"; - (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); + (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); Assert.NotNull(result); Assert.Equal(numAgents + 1, result.Count); @@ -225,7 +225,7 @@ public class AgentWorkflowBuilderTests barrier.Value = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); remaining.Value = 2; - (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.NotEmpty(updateText); Assert.NotNull(result); @@ -258,7 +258,7 @@ public class AgentWorkflowBuilderTests }), description: "nop")) .Build(); - (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.Equal("Hello from agent1", updateText); Assert.NotNull(result); @@ -296,7 +296,7 @@ public class AgentWorkflowBuilderTests .WithHandoff(initialAgent, nextAgent) .Build(); - (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.Equal("Hello from agent2", updateText); Assert.NotNull(result); @@ -406,7 +406,7 @@ public class AgentWorkflowBuilderTests .WithHandoff(secondAgent, thirdAgent) .Build(); - (string updateText, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.Contains("Hello from agent3", updateText); @@ -604,7 +604,7 @@ public class AgentWorkflowBuilderTests .WithHandoff(secondAgent, thirdAgent) .Build(); - (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.Equal("Hello from agent3", updateText); Assert.NotNull(result); @@ -651,7 +651,7 @@ public class AgentWorkflowBuilderTests for (int iter = 0; iter < 3; iter++) { const string UserInput = "abc"; - (string updateText, List? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); + (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); Assert.NotNull(result); Assert.Equal(maxIterations + 1, result.Count); @@ -680,36 +680,211 @@ public class AgentWorkflowBuilderTests } } - private static async Task<(string UpdateText, List? Result)> RunWorkflowAsync( - Workflow workflow, List input, ExecutionEnvironment executionEnvironment = ExecutionEnvironment.InProcess_Lockstep) + [Fact] + public async Task Handoffs_ReturnToPrevious_DisabledByDefault_SecondTurnRoutesViaCoordinatorAsync() { - StringBuilder sb = new(); + int coordinatorCallCount = 0; - InProcessExecutionEnvironment environment = executionEnvironment.ToWorkflowExecutionEnvironment(); - await using StreamingRun run = await environment.RunStreamingAsync(workflow, input); + var coordinator = new ChatClientAgent(new MockChatClient((messages, options) => + { + coordinatorCallCount++; + if (coordinatorCallCount == 1) + { + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); + } + return new(new ChatMessage(ChatRole.Assistant, "coordinator responded on turn 2")); + }), name: "coordinator"); + + var specialist = new ChatClientAgent(new MockChatClient((messages, options) => + new(new ChatMessage(ChatRole.Assistant, "specialist responded"))), + name: "specialist", description: "The specialist agent"); + + var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator) + .WithHandoff(coordinator, specialist) + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep; + + // Turn 1: coordinator hands off to specialist + WorkflowRunResult result = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], Environment, checkpointManager); + Assert.Equal(1, coordinatorCallCount); + + // Turn 2: without ReturnToPrevious, coordinator should be invoked again + _ = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "my id is 12345")], Environment, checkpointManager, result.LastCheckpoint); + Assert.Equal(2, coordinatorCallCount); + } + + [Fact] + public async Task Handoffs_ReturnToPrevious_Enabled_SecondTurnRoutesDirectlyToSpecialistAsync() + { + int coordinatorCallCount = 0; + int specialistCallCount = 0; + + var coordinator = new ChatClientAgent(new MockChatClient((messages, options) => + { + coordinatorCallCount++; + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); + }), name: "coordinator"); + + var specialist = new ChatClientAgent(new MockChatClient((messages, options) => + { + specialistCallCount++; + return new(new ChatMessage(ChatRole.Assistant, "specialist responded")); + }), name: "specialist", description: "The specialist agent"); + + var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator) + .WithHandoff(coordinator, specialist) + .EnableReturnToPrevious() + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep; + + // Turn 1: coordinator hands off to specialist + WorkflowRunResult result = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], Environment, checkpointManager); + Assert.Equal(1, coordinatorCallCount); + Assert.Equal(1, specialistCallCount); + + // Turn 2: with ReturnToPrevious, specialist should be invoked directly, coordinator should NOT be called again + _ = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "my id is 12345")], Environment, checkpointManager, result.LastCheckpoint); + Assert.Equal(1, coordinatorCallCount); // coordinator NOT called again + Assert.Equal(2, specialistCallCount); // specialist called again + } + + [Fact] + public async Task Handoffs_ReturnToPrevious_Enabled_BeforeAnyHandoff_RoutesViaInitialAgentAsync() + { + int coordinatorCallCount = 0; + + var coordinator = new ChatClientAgent(new MockChatClient((messages, options) => + { + coordinatorCallCount++; + return new(new ChatMessage(ChatRole.Assistant, "coordinator responded")); + }), name: "coordinator"); + + var specialist = new ChatClientAgent(new MockChatClient((messages, options) => + { + Assert.Fail("Specialist should not be invoked."); + return new(); + }), name: "specialist", description: "The specialist agent"); + + var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator) + .WithHandoff(coordinator, specialist) + .EnableReturnToPrevious() + .Build(); + + // First turn with no prior handoff: should route to initial (coordinator) agent + _ = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "hello")]); + Assert.Equal(1, coordinatorCallCount); + } + + [Fact] + public async Task Handoffs_ReturnToPrevious_Enabled_AfterHandoffBackToCoordinator_NextTurnRoutesViaCoordinatorAsync() + { + int coordinatorCallCount = 0; + int specialistCallCount = 0; + + var coordinator = new ChatClientAgent(new MockChatClient((messages, options) => + { + coordinatorCallCount++; + if (coordinatorCallCount == 1) + { + // First call: hand off to specialist + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); + } + // Subsequent calls: respond without handoff + return new(new ChatMessage(ChatRole.Assistant, "coordinator responded")); + }), name: "coordinator"); + + var specialist = new ChatClientAgent(new MockChatClient((messages, options) => + { + specialistCallCount++; + // Specialist hands back to coordinator + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", transferFuncName)])); + }), name: "specialist", description: "The specialist agent"); + + var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator) + .WithHandoff(coordinator, specialist) + .WithHandoff(specialist, coordinator) + .EnableReturnToPrevious() + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep; + + // Turn 1: coordinator → specialist → coordinator (specialist hands back) + WorkflowRunResult result = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], Environment, checkpointManager); + Assert.Equal(2, coordinatorCallCount); // called twice: initial handoff + receiving handback + Assert.Equal(1, specialistCallCount); // specialist called once, then handed back + + // Turn 2: after handoff back to coordinator, should route to coordinator (not specialist) + _ = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "never mind")], Environment, checkpointManager, result.LastCheckpoint); + Assert.Equal(3, coordinatorCallCount); // coordinator called again on turn 2 + Assert.Equal(1, specialistCallCount); // specialist NOT called + } + + private sealed record WorkflowRunResult(string UpdateText, List? Result, CheckpointInfo? LastCheckpoint); + + private static Task RunWorkflowCheckpointedAsync( + Workflow workflow, List input, ExecutionEnvironment executionEnvironment, CheckpointManager checkpointManager, CheckpointInfo? fromCheckpoint = null) + { + InProcessExecutionEnvironment environment = executionEnvironment.ToWorkflowExecutionEnvironment() + .WithCheckpointing(checkpointManager); + + return RunWorkflowCheckpointedAsync(workflow, input, environment, fromCheckpoint); + } + + private static async Task RunWorkflowCheckpointedAsync( + Workflow workflow, List input, InProcessExecutionEnvironment environment, CheckpointInfo? fromCheckpoint = null) + { + await using StreamingRun run = + fromCheckpoint != null ? await environment.ResumeStreamingAsync(workflow, fromCheckpoint) + : await environment.OpenStreamingAsync(workflow); + + await run.TrySendMessageAsync(input); await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); + StringBuilder sb = new(); WorkflowOutputEvent? output = null; + CheckpointInfo? lastCheckpoint = null; await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { - if (evt is AgentResponseUpdateEvent executorComplete) + switch (evt) { - sb.Append(executorComplete.Data); - } - else if (evt is WorkflowOutputEvent e) - { - output = e; - break; - } - else if (evt is WorkflowErrorEvent errorEvent) - { - Assert.Fail($"Workflow execution failed with error: {errorEvent.Exception}"); + case AgentResponseUpdateEvent executorComplete: + sb.Append(executorComplete.Data); + break; + + case WorkflowOutputEvent e: + output = e; + break; + + case WorkflowErrorEvent errorEvent: + Assert.Fail($"Workflow execution failed with error: {errorEvent.Exception}"); + break; + + case SuperStepCompletedEvent stepCompleted: + lastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint; + break; } } - return (sb.ToString(), output?.As>()); + return new(sb.ToString(), output?.As>(), lastCheckpoint); } + private static Task RunWorkflowAsync( + Workflow workflow, List input, ExecutionEnvironment executionEnvironment = ExecutionEnvironment.InProcess_Lockstep) + => RunWorkflowCheckpointedAsync(workflow, input, executionEnvironment.ToWorkflowExecutionEnvironment()); + private sealed class DoubleEchoAgentWithBarrier(string name, StrongBox> barrier, StrongBox remaining) : DoubleEchoAgent(name) { protected override async IAsyncEnumerable RunCoreStreamingAsync( diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/12_HandOff_HostAsAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/12_HandOff_HostAsAgent.cs index 3d88ed22ab..993a6d462b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/12_HandOff_HostAsAgent.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/12_HandOff_HostAsAgent.cs @@ -20,7 +20,7 @@ internal sealed class HandoffTestEchoAgent(string id, string name, string prefix { IEnumerable? handoffs = chatClientOptions.ChatOptions .Tools? - .Where(tool => tool.Name?.StartsWith(HandoffsWorkflowBuilder.FunctionPrefix, + .Where(tool => tool.Name?.StartsWith(HandoffWorkflowBuilder.FunctionPrefix, StringComparison.OrdinalIgnoreCase) is true); if (handoffs != null) @@ -58,7 +58,7 @@ internal static class Step12EntryPoint .Select(i => new HandoffTestEchoAgent($"{EchoAgentIdPrefix}{i}", $"{EchoAgentNamePrefix}{i}", EchoPrefixForAgent(i))) .ToArray(); - return new HandoffsWorkflowBuilder(echoAgents[0]) + return new HandoffWorkflowBuilder(echoAgents[0]) .WithHandoff(echoAgents[0], echoAgents[1]) .Build(); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs index 7bf00c4fb9..3fa17a34bb 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs @@ -794,7 +794,7 @@ public class WorkflowHostSmokeTests : AIAgentHostingExecutorTestsBase { // Arrange TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName); - Workflow handoffWorkflow = new HandoffsWorkflowBuilder(agent).Build(); + Workflow handoffWorkflow = new HandoffWorkflowBuilder(agent).Build(); return this.Run_AsAgent_OutgoingMessagesInHistoryAsync(handoffWorkflow, runAsync); } } From 0fcbe7e10553d901cc4969eef5c61532846f93e0 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Thu, 26 Mar 2026 22:27:17 +0000 Subject: [PATCH 17/22] .NET: [Breaking] Restructure agent skills to use multi-source architecture (#4871) * initial commit * address comments * address comments * address comments * address comments * rename executor to runner to align naming with python implementation * rename runner execute method to run method * remove poc leftovers and fix compilation issues * make script runner optional * remove unnecessary pragmas * make resources and scripts props virtual * address comments * update comment for name validation regex * address comments --- dotnet/agent-framework-dotnet.slnx | 2 +- .../Agent_Step01_BasicSkills/Program.cs | 50 -- .../Agent_Step01_BasicSkills/README.md | 63 -- .../skills/expense-report/SKILL.md | 40 - .../assets/expense-report-template.md | 5 - .../expense-report/references/POLICY_FAQ.md | 55 -- .../Agent_Step01_FileBasedSkills.csproj} | 4 + .../Agent_Step01_FileBasedSkills/Program.cs | 48 ++ .../Agent_Step01_FileBasedSkills/README.md | 51 ++ .../skills/unit-converter/SKILL.md | 11 + .../references/conversion-table.md | 10 + .../skills/unit-converter/scripts/convert.py | 29 + .../samples/02-agents/AgentSkills/README.md | 2 +- .../AgentSkills/SubprocessScriptRunner.cs | 137 ++++ .../A2AServer/HostAgentFactory.cs | 6 +- .../Microsoft.Agents.AI/Skills/AgentSkill.cs | 58 ++ .../Skills/AgentSkillFrontmatter.cs | 196 +++++ .../Skills/AgentSkillResource.cs | 46 ++ .../Skills/AgentSkillScript.cs | 47 ++ .../Skills/AgentSkillsProvider.cs | 383 +++++++++ .../Skills/AgentSkillsProviderBuilder.cs | 192 +++++ .../Skills/AgentSkillsProviderOptions.cs | 37 + .../Skills/AgentSkillsSource.cs | 24 + .../Skills/AggregatingAgentSkillsSource.cs | 45 ++ .../DeduplicatingAgentSkillsSource.cs | 58 ++ .../Decorators/DelegatingAgentSkillsSource.cs | 41 + .../Decorators/FilteringAgentSkillsSource.cs | 70 ++ .../Skills/File/AgentFileSkill.cs | 57 ++ .../Skills/File/AgentFileSkillResource.cs | 43 + .../Skills/File/AgentFileSkillScript.cs | 56 ++ .../Skills/File/AgentFileSkillScriptRunner.cs | 27 + .../AgentFileSkillsSource.cs} | 368 +++++---- .../File/AgentFileSkillsSourceOptions.cs | 33 + .../Skills/FileAgentSkill.cs | 56 -- .../Skills/FileAgentSkillsProvider.cs | 222 ----- .../Skills/FileAgentSkillsProviderOptions.cs | 32 - .../Skills/SkillFrontmatter.cs | 32 - .../AgentSkills/AgentFileSkillScriptTests.cs | 103 +++ .../AgentFileSkillsSourceScriptTests.cs | 255 ++++++ .../AgentSkillFrontmatterValidatorTests.cs | 260 ++++++ .../AgentSkillsProviderBuilderTests.cs | 229 ++++++ .../AgentSkills/AgentSkillsProviderTests.cs | 765 ++++++++++++++++++ .../DeduplicatingAgentSkillsSourceTests.cs | 99 +++ .../AgentSkills/FileAgentSkillLoaderTests.cs | 494 +++++------ .../FileAgentSkillsProviderTests.cs | 266 ------ .../FilteringAgentSkillsSourceTests.cs | 120 +++ .../AgentSkills/TestSkillTypes.cs | 72 ++ 47 files changed, 4086 insertions(+), 1213 deletions(-) delete mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Program.cs delete mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/README.md delete mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md delete mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md delete mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md rename dotnet/samples/02-agents/AgentSkills/{Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj => Agent_Step01_FileBasedSkills/Agent_Step01_FileBasedSkills.csproj} (86%) create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/SKILL.md create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/references/conversion-table.md create mode 100644 dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/scripts/convert.py create mode 100644 dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillResource.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsSource.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/AggregatingAgentSkillsSource.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DeduplicatingAgentSkillsSource.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DelegatingAgentSkillsSource.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillResource.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs rename dotnet/src/Microsoft.Agents.AI/Skills/{FileAgentSkillLoader.cs => File/AgentFileSkillsSource.cs} (51%) create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillFrontmatterValidatorTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderBuilderTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/TestSkillTypes.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index a4ffe13958..a5b93487b3 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -104,7 +104,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Program.cs deleted file mode 100644 index 9b0a4b4f99..0000000000 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Program.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample demonstrates how to use Agent Skills with a ChatClientAgent. -// Agent Skills are modular packages of instructions and resources that extend an agent's capabilities. -// Skills follow the progressive disclosure pattern: advertise -> load -> read resources. -// -// This sample includes the expense-report skill: -// - Policy-based expense filing with references and assets - -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using OpenAI.Responses; - -// --- Configuration --- -string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - -// --- Skills Provider --- -// Discovers skills from the 'skills' directory and makes them available to the agent -var skillsProvider = new FileAgentSkillsProvider(skillPath: Path.Combine(AppContext.BaseDirectory, "skills")); - -// --- Agent Setup --- -AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) - .GetResponsesClient() - .AsAIAgent(new ChatClientAgentOptions - { - Name = "SkillsAgent", - ChatOptions = new() - { - Instructions = "You are a helpful assistant.", - }, - AIContextProviders = [skillsProvider], - }, - model: deploymentName); - -// --- Example 1: Expense policy question (loads FAQ resource) --- -Console.WriteLine("Example 1: Checking expense policy FAQ"); -Console.WriteLine("---------------------------------------"); -AgentResponse response1 = await agent.RunAsync("Are tips reimbursable? I left a 25% tip on a taxi ride and want to know if that's covered."); -Console.WriteLine($"Agent: {response1.Text}\n"); - -// --- Example 2: Filing an expense report (multi-turn with template asset) --- -Console.WriteLine("Example 2: Filing an expense report"); -Console.WriteLine("---------------------------------------"); -AgentSession session = await agent.CreateSessionAsync(); -AgentResponse response2 = await agent.RunAsync("I had 3 client dinners and a $1,200 flight last week. Return a draft expense report and ask about any missing details.", - session); -Console.WriteLine($"Agent: {response2.Text}\n"); diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/README.md deleted file mode 100644 index 78099fa8a5..0000000000 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/README.md +++ /dev/null @@ -1,63 +0,0 @@ -# Agent Skills Sample - -This sample demonstrates how to use **Agent Skills** with a `ChatClientAgent` in the Microsoft Agent Framework. - -## What are Agent Skills? - -Agent Skills are modular packages of instructions and resources that enable AI agents to perform specialized tasks. They follow the [Agent Skills specification](https://agentskills.io/) and implement the progressive disclosure pattern: - -1. **Advertise**: Skills are advertised with name + description (~100 tokens per skill) -2. **Load**: Full instructions are loaded on-demand via `load_skill` tool -3. **Resources**: References and other files loaded via `read_skill_resource` tool - -## Skills Included - -### expense-report -Policy-based expense filing with spending limits, receipt requirements, and approval workflows. -- `references/POLICY_FAQ.md` — Detailed expense policy Q&A -- `assets/expense-report-template.md` — Submission template - -## Project Structure - -``` -Agent_Step01_BasicSkills/ -├── Program.cs -├── Agent_Step01_BasicSkills.csproj -└── skills/ - └── expense-report/ - ├── SKILL.md - ├── references/ - │ └── POLICY_FAQ.md - └── assets/ - └── expense-report-template.md -``` - -## Running the Sample - -### Prerequisites -- .NET 10.0 SDK -- Azure OpenAI endpoint with a deployed model - -### Setup -1. Set environment variables: - ```bash - export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/" - export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" - ``` - -2. Run the sample: - ```bash - dotnet run - ``` - -### Examples - -The sample runs two examples: - -1. **Expense policy FAQ** — Asks about tip reimbursement; the agent loads the expense-report skill and reads the FAQ resource -2. **Filing an expense report** — Multi-turn conversation to draft an expense report using the template asset - -## Learn More - -- [Agent Skills Specification](https://agentskills.io/) -- [Microsoft Agent Framework Documentation](../../../../../docs/) diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md deleted file mode 100644 index fc6c83cf30..0000000000 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -name: expense-report -description: File and validate employee expense reports according to Contoso company policy. Use when asked about expense submissions, reimbursement rules, receipt requirements, spending limits, or expense categories. -metadata: - author: contoso-finance - version: "2.1" ---- - -# Expense Report - -## Categories and Limits - -| Category | Limit | Receipt | Approval | -|---|---|---|---| -| Meals — solo | $50/day | >$25 | No | -| Meals — team/client | $75/person | Always | Manager if >$200 total | -| Lodging | $250/night | Always | Manager if >3 nights | -| Ground transport | $100/day | >$15 | No | -| Airfare | Economy | Always | Manager; VP if >$1,500 | -| Conference/training | $2,000/event | Always | Manager + L&D | -| Office supplies | $100 | Yes | No | -| Software/subscriptions | $50/month | Yes | Manager if >$200/year | - -## Filing Process - -1. Collect receipts — must show vendor, date, amount, payment method. -2. Categorize per table above. -3. Use template: [assets/expense-report-template.md](assets/expense-report-template.md). -4. For client/team meals: list attendee names and business purpose. -5. Submit — auto-approved if <$500; manager if $500–$2,000; VP if >$2,000. -6. Reimbursement: 10 business days via direct deposit. - -## Policy Rules - -- Submit within 30 days of transaction. -- Alcohol is never reimbursable. -- Foreign currency: convert to USD at transaction-date rate; note original currency and amount. -- Mixed personal/business travel: only business portion reimbursable; provide comparison quotes. -- Lost receipts (>$25): file Lost Receipt Affidavit from Finance. Max 2 per quarter. -- For policy questions not covered above, consult the FAQ: [references/POLICY_FAQ.md](references/POLICY_FAQ.md). Answers should be based on what this document and the FAQ state. diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md deleted file mode 100644 index 3f7c7dc36c..0000000000 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md +++ /dev/null @@ -1,5 +0,0 @@ -# Expense Report Template - -| Date | Category | Vendor | Description | Amount (USD) | Original Currency | Original Amount | Attendees | Business Purpose | Receipt Attached | -|------|----------|--------|-------------|--------------|-------------------|-----------------|-----------|------------------|------------------| -| | | | | | | | | | Yes or No | diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md deleted file mode 100644 index 8e971192f8..0000000000 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md +++ /dev/null @@ -1,55 +0,0 @@ -# Expense Policy — Frequently Asked Questions - -## Meals - -**Q: Can I expense coffee or snacks during the workday?** -A: Daily coffee/snacks under $10 are not reimbursable (considered personal). Coffee purchased during a client meeting or team working session is reimbursable as a team meal. - -**Q: What if a team dinner exceeds the per-person limit?** -A: The $75/person limit applies as a guideline. Overages up to 20% are accepted with a written justification (e.g., "client dinner at venue chosen by client"). Overages beyond 20% require pre-approval from your VP. - -**Q: Do I need to list every attendee?** -A: Yes. For client meals, list the client's name and company. For team meals, list all employee names. For groups over 10, you may attach a separate attendee list. - -## Travel - -**Q: Can I book a premium economy or business class flight?** -A: Economy class is the standard. Premium economy is allowed for flights over 6 hours. Business class requires VP pre-approval and is generally reserved for flights over 10 hours or medical accommodation. - -**Q: What about ride-sharing (Uber/Lyft) vs. rental cars?** -A: Use ride-sharing for trips under 30 miles round-trip. Rent a car for multi-day travel or when ride-sharing would exceed $100/day. Always choose the compact/standard category unless traveling with 3+ people. - -**Q: Are tips reimbursable?** -A: Tips up to 20% are reimbursable for meals, taxi/ride-share, and hotel housekeeping. Tips above 20% require justification. - -## Lodging - -**Q: What if the $250/night limit isn't enough for the city I'm visiting?** -A: For high-cost cities (New York, San Francisco, London, Tokyo, Sydney), the limit is automatically increased to $350/night. No additional approval is needed. For other locations where rates are unusually high (e.g., during a major conference), request a per-trip exception from your manager before booking. - -**Q: Can I stay with friends/family instead and get a per-diem?** -A: No. Contoso reimburses actual lodging costs only, not per-diems. - -## Subscriptions and Software - -**Q: Can I expense a personal productivity tool?** -A: Software must be directly related to your job function. Tools like IDE licenses, design software, or project management apps are reimbursable. General productivity apps (note-taking, personal calendar) are not, unless your manager confirms a business need in writing. - -**Q: What about annual subscriptions?** -A: Annual subscriptions over $200 require manager approval before purchase. Submit the approval email with your expense report. - -## Receipts and Documentation - -**Q: My receipt is faded/damaged. What do I do?** -A: Try to obtain a duplicate from the vendor. If not possible, submit a Lost Receipt Affidavit (available from the Finance SharePoint site). You're limited to 2 affidavits per quarter. - -**Q: Do I need a receipt for parking meters or tolls?** -A: For amounts under $15, no receipt is required — just note the date, location, and amount. For $15 and above, a receipt or bank/credit card statement excerpt is required. - -## Approval and Reimbursement - -**Q: My manager is on leave. Who approves my report?** -A: Expense reports can be approved by your skip-level manager or any manager designated as an alternate approver in the expense system. - -**Q: Can I submit expenses from a previous quarter?** -A: The standard 30-day window applies. Expenses older than 30 days require a written explanation and VP approval. Expenses older than 90 days are not reimbursable except in extraordinary circumstances (extended leave, medical emergency) with CFO approval. diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Agent_Step01_FileBasedSkills.csproj similarity index 86% rename from dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj rename to dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Agent_Step01_FileBasedSkills.csproj index 2a503bbfb2..7e7e9ef0fa 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Agent_Step01_FileBasedSkills.csproj @@ -14,6 +14,10 @@ + + + + diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs new file mode 100644 index 0000000000..b787bb86a3 --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Program.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to use file-based Agent Skills with a ChatClientAgent. +// Skills are discovered from SKILL.md files on disk and follow the progressive disclosure pattern: +// 1. Advertise — skill names and descriptions in the system prompt +// 2. Load — full instructions loaded on demand via load_skill tool +// 3. Read resources — reference files read via read_skill_resource tool +// 4. Run scripts — scripts executed via run_skill_script tool with a subprocess executor +// +// This sample uses a unit-converter skill that converts between miles, kilometers, pounds, and kilograms. + +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using OpenAI.Responses; + +// --- Configuration --- +string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +// --- Skills Provider --- +// Discovers skills from the 'skills' directory containing SKILL.md files. +// The script runner runs file-based scripts (e.g. Python) as local subprocesses. +var skillsProvider = new AgentSkillsProvider( + Path.Combine(AppContext.BaseDirectory, "skills"), + SubprocessScriptRunner.RunAsync); +// --- Agent Setup --- +AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) + .GetResponsesClient() + .AsAIAgent(new ChatClientAgentOptions + { + Name = "UnitConverterAgent", + ChatOptions = new() + { + Instructions = "You are a helpful assistant that can convert units.", + }, + AIContextProviders = [skillsProvider], + }, + model: deploymentName); + +// --- Example: Unit conversion --- +Console.WriteLine("Converting units with file-based skills"); +Console.WriteLine(new string('-', 60)); + +AgentResponse response = await agent.RunAsync( + "How many kilometers is a marathon (26.2 miles)? And how many pounds is 75 kilograms?"); + +Console.WriteLine($"Agent: {response.Text}"); diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md new file mode 100644 index 0000000000..41b813b98f --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/README.md @@ -0,0 +1,51 @@ +# File-Based Agent Skills Sample + +This sample demonstrates how to use **file-based Agent Skills** with a `ChatClientAgent`. + +## What it demonstrates + +- Discovering skills from `SKILL.md` files on disk via `AgentFileSkillsSource` +- The progressive disclosure pattern: advertise → load → read resources → run scripts +- Using the `AgentSkillsProvider` constructor with a skill directory path and script executor +- Running file-based scripts (Python) via a subprocess-based executor + +## Skills Included + +### unit-converter + +Converts between common units (miles↔km, pounds↔kg) using a multiplication factor. + +- `references/conversion-table.md` — Conversion factor table +- `scripts/convert.py` — Python script that performs the conversion + +## Running the Sample + +### Prerequisites + +- .NET 10.0 SDK +- Azure OpenAI endpoint with a deployed model +- Python 3 installed and available as `python3` on your PATH + +### Setup + +```bash +export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/" +export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" +``` + +### Run + +```bash +dotnet run +``` + +### Expected Output + +``` +Converting units with file-based skills +------------------------------------------------------------ +Agent: Here are your conversions: + +1. **26.2 miles → 42.16 km** (a marathon distance) +2. **75 kg → 165.35 lbs** +``` diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/SKILL.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/SKILL.md new file mode 100644 index 0000000000..6a8e692ff2 --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/SKILL.md @@ -0,0 +1,11 @@ +--- +name: unit-converter +description: Convert between common units using a multiplication factor. Use when asked to convert miles, kilometers, pounds, or kilograms. +--- + +## Usage + +When the user requests a unit conversion: +1. First, review `references/conversion-table.md` to find the correct factor +2. Run the `scripts/convert.py` script with `--value --factor ` (e.g. `--value 26.2 --factor 1.60934`) +3. Present the converted value clearly with both units diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/references/conversion-table.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/references/conversion-table.md new file mode 100644 index 0000000000..7a0160b854 --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/references/conversion-table.md @@ -0,0 +1,10 @@ +# Conversion Tables + +Formula: **result = value × factor** + +| From | To | Factor | +|-------------|-------------|----------| +| miles | kilometers | 1.60934 | +| kilometers | miles | 0.621371 | +| pounds | kilograms | 0.453592 | +| kilograms | pounds | 2.20462 | diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/scripts/convert.py b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/scripts/convert.py new file mode 100644 index 0000000000..228c8809ff --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/skills/unit-converter/scripts/convert.py @@ -0,0 +1,29 @@ +# Unit conversion script +# Converts a value using a multiplication factor: result = value × factor +# +# Usage: +# python scripts/convert.py --value 26.2 --factor 1.60934 +# python scripts/convert.py --value 75 --factor 2.20462 + +import argparse +import json + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Convert a value using a multiplication factor.", + epilog="Examples:\n" + " python scripts/convert.py --value 26.2 --factor 1.60934\n" + " python scripts/convert.py --value 75 --factor 2.20462", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--value", type=float, required=True, help="The numeric value to convert.") + parser.add_argument("--factor", type=float, required=True, help="The conversion factor from the table.") + args = parser.parse_args() + + result = round(args.value * args.factor, 4) + print(json.dumps({"value": args.value, "factor": args.factor, "result": result})) + + +if __name__ == "__main__": + main() diff --git a/dotnet/samples/02-agents/AgentSkills/README.md b/dotnet/samples/02-agents/AgentSkills/README.md index 8488ec9eed..75b850f077 100644 --- a/dotnet/samples/02-agents/AgentSkills/README.md +++ b/dotnet/samples/02-agents/AgentSkills/README.md @@ -4,4 +4,4 @@ Samples demonstrating Agent Skills capabilities. | Sample | Description | |--------|-------------| -| [Agent_Step01_BasicSkills](Agent_Step01_BasicSkills/) | Using Agent Skills with a ChatClientAgent, including progressive disclosure and skill resources | +| [Agent_Step01_FileBasedSkills](Agent_Step01_FileBasedSkills/) | Define skills as `SKILL.md` files on disk with reference documents. Uses a unit-converter skill. | diff --git a/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs b/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs new file mode 100644 index 0000000000..e95bde61df --- /dev/null +++ b/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Sample subprocess-based skill script runner. +// Executes file-based skill scripts as local subprocesses. +// This is provided for demonstration purposes only. + +using System.Diagnostics; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +/// +/// Executes file-based skill scripts as local subprocesses. +/// +/// +/// This runner uses the script's absolute path, converts the arguments +/// to CLI flags, and returns captured output. It is intended for +/// demonstration purposes only. +/// +internal static class SubprocessScriptRunner +{ + /// + /// Runs a skill script as a local subprocess. + /// + public static async Task RunAsync( + AgentFileSkill skill, + AgentFileSkillScript script, + AIFunctionArguments arguments, + CancellationToken cancellationToken) + { + if (!File.Exists(script.FullPath)) + { + return $"Error: Script file not found: {script.FullPath}"; + } + + string extension = Path.GetExtension(script.FullPath); + string? interpreter = extension switch + { + ".py" => "python3", + ".js" => "node", + ".sh" => "bash", + ".ps1" => "pwsh", + _ => null, + }; + + var startInfo = new ProcessStartInfo + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = Path.GetDirectoryName(script.FullPath) ?? ".", + }; + + if (interpreter is not null) + { + startInfo.FileName = interpreter; + startInfo.ArgumentList.Add(script.FullPath); + } + else + { + startInfo.FileName = script.FullPath; + } + + if (arguments is not null) + { + foreach (var (key, value) in arguments) + { + if (value is bool boolValue) + { + if (boolValue) + { + startInfo.ArgumentList.Add(NormalizeKey(key)); + } + } + else if (value is not null) + { + startInfo.ArgumentList.Add(NormalizeKey(key)); + startInfo.ArgumentList.Add(value.ToString()!); + } + } + } + + Process? process = null; + try + { + process = Process.Start(startInfo); + if (process is null) + { + return $"Error: Failed to start process for script '{script.Name}'."; + } + + Task outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + Task errorTask = process.StandardError.ReadToEndAsync(cancellationToken); + + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + + string output = await outputTask.ConfigureAwait(false); + string error = await errorTask.ConfigureAwait(false); + + if (!string.IsNullOrEmpty(error)) + { + output += $"\nStderr:\n{error}"; + } + + if (process.ExitCode != 0) + { + output += $"\nScript exited with code {process.ExitCode}"; + } + + return string.IsNullOrEmpty(output) ? "(no output)" : output.Trim(); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Kill the process on cancellation to avoid leaving orphaned subprocesses. + process?.Kill(entireProcessTree: true); + throw; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return $"Error: Failed to execute script '{script.Name}': {ex.Message}"; + } + finally + { + process?.Dispose(); + } + } + + /// + /// Normalizes a parameter key to a consistent --flag format. + /// Models may return keys with or without leading dashes (e.g., "value" vs "--value"). + /// + private static string NormalizeKey(string key) => "--" + key.TrimStart('-'); +} diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs index 584b7db422..1149f9a293 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs @@ -59,7 +59,7 @@ internal static class HostAgentFactory PushNotifications = false, }; - var invoiceQuery = new AgentSkill() + var invoiceQuery = new A2A.AgentSkill() { Id = "id_invoice_agent", Name = "InvoiceQuery", @@ -91,7 +91,7 @@ internal static class HostAgentFactory PushNotifications = false, }; - var policyQuery = new AgentSkill() + var policyQuery = new A2A.AgentSkill() { Id = "id_policy_agent", Name = "PolicyAgent", @@ -123,7 +123,7 @@ internal static class HostAgentFactory PushNotifications = false, }; - var logisticsQuery = new AgentSkill() + var logisticsQuery = new A2A.AgentSkill() { Id = "id_logistics_agent", Name = "LogisticsQuery", diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs new file mode 100644 index 0000000000..5f0a66808d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Abstract base class for all agent skills. +/// +/// +/// +/// A skill represents a domain-specific capability with instructions, resources, and scripts. +/// Concrete implementations include (filesystem-backed). +/// +/// +/// Skill metadata follows the Agent Skills specification. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public abstract class AgentSkill +{ + /// + /// Gets the frontmatter metadata for this skill. + /// + /// + /// Contains the L1 discovery metadata (name, description, license, compatibility, etc.) + /// as defined by the Agent Skills specification. + /// + public abstract AgentSkillFrontmatter Frontmatter { get; } + + /// + /// Gets the full skill content. + /// + /// + /// For file-based skills this is the raw SKILL.md file content. + /// + public abstract string Content { get; } + + /// + /// Gets the resources associated with this skill, or if none. + /// + /// + /// The default implementation returns . + /// Override this property in derived classes to provide skill-specific resources. + /// + public virtual IReadOnlyList? Resources => null; + + /// + /// Gets the scripts associated with this skill, or if none. + /// + /// + /// The default implementation returns . + /// Override this property in derived classes to provide skill-specific scripts. + /// + public virtual IReadOnlyList? Scripts => null; +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs new file mode 100644 index 0000000000..df087ff2bb --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillFrontmatter.cs @@ -0,0 +1,196 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Represents the YAML frontmatter metadata parsed from a SKILL.md file. +/// +/// +/// +/// Frontmatter is the L1 (discovery) layer of the +/// Agent Skills specification. +/// It contains the minimal metadata needed to advertise a skill in the system prompt +/// without loading the full skill content. +/// +/// +/// The constructor validates the name and description against specification rules +/// and throws if either value is invalid. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentSkillFrontmatter +{ + /// + /// Maximum allowed length for the skill name. + /// + internal const int MaxNameLength = 64; + + /// + /// Maximum allowed length for the skill description. + /// + internal const int MaxDescriptionLength = 1024; + + /// + /// Maximum allowed length for the compatibility field. + /// + internal const int MaxCompatibilityLength = 500; + + // Validates skill names per the Agent Skills specification (https://agentskills.io/specification#frontmatter): + // lowercase letters, numbers, and hyphens only; must not start or end with a hyphen; must not contain consecutive hyphens. + private static readonly Regex s_validNameRegex = new("^[a-z0-9]([a-z0-9]*-[a-z0-9])*[a-z0-9]*$", RegexOptions.Compiled); + + private string? _compatibility; + + /// + /// Initializes a new instance of the class. + /// + /// Skill name in kebab-case. + /// Skill description for discovery. + /// Optional compatibility information (max 500 chars). + /// + /// Thrown when , , or violates the + /// Agent Skills specification rules. + /// + public AgentSkillFrontmatter(string name, string description, string? compatibility = null) + { + if (!ValidateName(name, out string? reason) || + !ValidateDescription(description, out reason) || + !ValidateCompatibility(compatibility, out reason)) + { + throw new ArgumentException(reason); + } + + this.Name = name; + this.Description = description; + this._compatibility = compatibility; + } + + /// + /// Gets the skill name. Lowercase letters, numbers, and hyphens only; no leading, trailing, or consecutive hyphens. + /// + public string Name { get; } + + /// + /// Gets the skill description. Used for discovery in the system prompt. + /// + public string Description { get; } + + /// + /// Gets or sets an optional license name or reference. + /// + public string? License { get; set; } + + /// + /// Gets or sets optional compatibility information (max 500 chars). + /// + /// + /// Thrown when the value exceeds characters. + /// + public string? Compatibility + { + get => this._compatibility; + set + { + if (!ValidateCompatibility(value, out string? reason)) + { + throw new ArgumentException(reason); + } + + this._compatibility = value; + } + } + + /// + /// Gets or sets optional space-delimited list of pre-approved tools. + /// + public string? AllowedTools { get; set; } + + /// + /// Gets or sets the arbitrary key-value metadata for this skill. + /// + public AdditionalPropertiesDictionary? Metadata { get; set; } + + /// + /// Validates a skill name against specification rules. + /// + /// The skill name to validate (may be ). + /// When validation fails, contains a human-readable description of the failure. + /// if the name is valid; otherwise, . + public static bool ValidateName( + string? name, + [NotNullWhen(false)] out string? reason) + { + if (string.IsNullOrWhiteSpace(name)) + { + reason = "Skill name is required."; + return false; + } + + if (name.Length > MaxNameLength) + { + reason = $"Skill name must be {MaxNameLength} characters or fewer."; + return false; + } + + if (!s_validNameRegex.IsMatch(name)) + { + reason = "Skill name must use only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen or contain consecutive hyphens."; + return false; + } + + reason = null; + return true; + } + + /// + /// Validates a skill description against specification rules. + /// + /// The skill description to validate (may be ). + /// When validation fails, contains a human-readable description of the failure. + /// if the description is valid; otherwise, . + public static bool ValidateDescription( + string? description, + [NotNullWhen(false)] out string? reason) + { + if (string.IsNullOrWhiteSpace(description)) + { + reason = "Skill description is required."; + return false; + } + + if (description.Length > MaxDescriptionLength) + { + reason = $"Skill description must be {MaxDescriptionLength} characters or fewer."; + return false; + } + + reason = null; + return true; + } + + /// + /// Validates an optional skill compatibility value against specification rules. + /// + /// The optional compatibility value to validate (may be ). + /// When validation fails, contains a human-readable description of the failure. + /// if the value is valid; otherwise, . + public static bool ValidateCompatibility( + string? compatibility, + [NotNullWhen(false)] out string? reason) + { + if (compatibility?.Length > MaxCompatibilityLength) + { + reason = $"Skill compatibility must be {MaxCompatibilityLength} characters or fewer."; + return false; + } + + reason = null; + return true; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillResource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillResource.cs new file mode 100644 index 0000000000..b3cfc3f117 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillResource.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Abstract base class for skill resources. A resource provides supplementary content (references, assets) to a skill. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public abstract class AgentSkillResource +{ + /// + /// Initializes a new instance of the class. + /// + /// The resource name (e.g., relative path or identifier). + /// An optional description of the resource. + protected AgentSkillResource(string name, string? description = null) + { + this.Name = Throw.IfNullOrWhitespace(name); + this.Description = description; + } + + /// + /// Gets the resource name. + /// + public string Name { get; } + + /// + /// Gets the optional resource description. + /// + public string? Description { get; } + + /// + /// Reads the resource content asynchronously. + /// + /// Optional service provider for dependency injection. + /// Cancellation token. + /// The resource content. + public abstract Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs new file mode 100644 index 0000000000..ad647d2eb0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Abstract base class for skill scripts. A script represents an executable action associated with a skill. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public abstract class AgentSkillScript +{ + /// + /// Initializes a new instance of the class. + /// + /// The script name. + /// An optional description of the script. + protected AgentSkillScript(string name, string? description = null) + { + this.Name = Throw.IfNullOrWhitespace(name); + this.Description = description; + } + + /// + /// Gets the script name. + /// + public string Name { get; } + + /// + /// Gets the optional script description. + /// + public string? Description { get; } + + /// + /// Runs the script with the given arguments. + /// + /// The skill that owns this script. + /// Arguments for script execution. + /// Cancellation token. + /// The script execution result. + public abstract Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs new file mode 100644 index 0000000000..f2f87851c0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs @@ -0,0 +1,383 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Security; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// An that exposes agent skills from one or more instances. +/// +/// +/// +/// This provider implements the progressive disclosure pattern from the +/// Agent Skills specification: +/// +/// +/// Advertise — skill names and descriptions are injected into the system prompt. +/// Load — the full skill body is returned via the load_skill tool. +/// Read resources — supplementary content is read on demand via the read_skill_resource tool. +/// Run scripts — scripts are executed via the run_skill_script tool (when scripts exist). +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed partial class AgentSkillsProvider : AIContextProvider +{ + /// + /// Placeholder token for the generated skills list in the prompt template. + /// + private const string SkillsPlaceholder = "{skills}"; + + /// + /// Placeholder token for the script instructions in the prompt template. + /// + private const string ScriptInstructionsPlaceholder = "{script_instructions}"; + + /// + /// Placeholder token for the resource instructions in the prompt template. + /// + private const string ResourceInstructionsPlaceholder = "{resource_instructions}"; + + private const string DefaultSkillsInstructionPrompt = + """ + You have access to skills containing domain-specific knowledge and capabilities. + Each skill provides specialized instructions, reference documents, and assets for specific tasks. + + + {skills} + + + When a task aligns with a skill's domain, follow these steps in exact order: + - Use `load_skill` to retrieve the skill's instructions. + - Follow the provided guidance. + {resource_instructions} + {script_instructions} + Only load what is needed, when it is needed. + """; + + private readonly AgentSkillsSource _source; + private readonly AgentSkillsProviderOptions? _options; + private readonly ILogger _logger; + private Task? _contextTask; + + /// + /// Initializes a new instance of the class + /// that discovers file-based skills from a single directory. + /// Duplicate skill names are automatically deduplicated (first occurrence wins). + /// + /// Path to search for skills. + /// Optional delegate that runs file-based scripts. Required only when skills contain scripts. + /// Optional options that control skill discovery behavior. + /// Optional provider configuration. + /// Optional logger factory. + public AgentSkillsProvider( + string skillPath, + AgentFileSkillScriptRunner? scriptRunner = null, + AgentFileSkillsSourceOptions? fileOptions = null, + AgentSkillsProviderOptions? options = null, + ILoggerFactory? loggerFactory = null) + : this([Throw.IfNull(skillPath)], scriptRunner, fileOptions, options, loggerFactory) + { + } + + /// + /// Initializes a new instance of the class + /// that discovers file-based skills from multiple directories. + /// Duplicate skill names are automatically deduplicated (first occurrence wins). + /// + /// Paths to search for skills. + /// Optional delegate that runs file-based scripts. Required only when skills contain scripts. + /// Optional options that control skill discovery behavior. + /// Optional provider configuration. + /// Optional logger factory. + public AgentSkillsProvider( + IEnumerable skillPaths, + AgentFileSkillScriptRunner? scriptRunner = null, + AgentFileSkillsSourceOptions? fileOptions = null, + AgentSkillsProviderOptions? options = null, + ILoggerFactory? loggerFactory = null) + : this( + new DeduplicatingAgentSkillsSource( + new AgentFileSkillsSource(skillPaths, scriptRunner, fileOptions, loggerFactory), + loggerFactory), + options, + loggerFactory) + { + } + + /// + /// Initializes a new instance of the class + /// from a custom . Unlike other constructors, this one does not + /// apply automatic deduplication, allowing callers to customize deduplication behavior via the source pipeline. + /// + /// The skill source providing skills. + /// Optional configuration. + /// Optional logger factory. + public AgentSkillsProvider(AgentSkillsSource source, AgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null) + { + this._source = Throw.IfNull(source); + this._options = options; + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + + if (options?.SkillsInstructionPrompt is string prompt) + { + ValidatePromptTemplate(prompt, nameof(options)); + } + } + + /// + protected override async ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) + { + if (this._options?.DisableCaching == true) + { + return await this.CreateContextAsync(context, cancellationToken).ConfigureAwait(false); + } + + return await this.GetOrCreateContextAsync(context, cancellationToken).ConfigureAwait(false); + } + + private async Task CreateContextAsync(InvokingContext context, CancellationToken cancellationToken) + { + var skills = await this._source.GetSkillsAsync(cancellationToken).ConfigureAwait(false); + if (skills is not { Count: > 0 }) + { + return await base.ProvideAIContextAsync(context, cancellationToken).ConfigureAwait(false); + } + + bool hasScripts = skills.Any(s => s.Scripts is { Count: > 0 }); + bool hasResources = skills.Any(s => s.Resources is { Count: > 0 }); + + return new AIContext + { + Instructions = this.BuildSkillsInstructions(skills, includeScriptInstructions: hasScripts, hasResources), + Tools = this.BuildTools(skills, hasScripts, hasResources), + }; + } + + private async Task GetOrCreateContextAsync(InvokingContext context, CancellationToken cancellationToken) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + if (Interlocked.CompareExchange(ref this._contextTask, tcs.Task, null) is { } existing) + { + return await existing.ConfigureAwait(false); + } + + try + { + var result = await this.CreateContextAsync(context, cancellationToken).ConfigureAwait(false); + tcs.SetResult(result); + return result; + } + catch (Exception ex) + { + this._contextTask = null; + tcs.TrySetException(ex); + throw; + } + } + + private IList BuildTools(IList skills, bool hasScripts, bool hasResources) + { + IList tools = + [ + AIFunctionFactory.Create( + (string skillName) => this.LoadSkill(skills, skillName), + name: "load_skill", + description: "Loads the full content of a specific skill"), + ]; + + if (hasResources) + { + tools.Add(AIFunctionFactory.Create( + (string skillName, string resourceName, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default) => + this.ReadSkillResourceAsync(skills, skillName, resourceName, serviceProvider, cancellationToken), + name: "read_skill_resource", + description: "Reads a resource associated with a skill, such as references, assets, or dynamic data.")); + } + + if (!hasScripts) + { + return tools; + } + + AIFunction scriptFunction = AIFunctionFactory.Create( + (string skillName, string scriptName, IDictionary? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) => + this.RunSkillScriptAsync(skills, skillName, scriptName, arguments, serviceProvider, cancellationToken), + name: "run_skill_script", + description: "Runs a script associated with a skill."); + + if (this._options?.ScriptApproval == true) + { + return [.. tools, new ApprovalRequiredAIFunction(scriptFunction)]; + } + + return [.. tools, scriptFunction]; + } + + private string? BuildSkillsInstructions(IList skills, bool includeScriptInstructions, bool includeResourceInstructions) + { + string promptTemplate = this._options?.SkillsInstructionPrompt ?? DefaultSkillsInstructionPrompt; + + var sb = new StringBuilder(); + foreach (var skill in skills.OrderBy(s => s.Frontmatter.Name, StringComparer.Ordinal)) + { + sb.AppendLine(" "); + sb.AppendLine($" {SecurityElement.Escape(skill.Frontmatter.Name)}"); + sb.AppendLine($" {SecurityElement.Escape(skill.Frontmatter.Description)}"); + sb.AppendLine(" "); + } + + string resourceInstruction = includeResourceInstructions + ? """ + - Use `read_skill_resource` to read any referenced resources, using the name exactly as listed + (e.g. `"style-guide"` not `"style-guide.md"`, `"references/FAQ.md"` not `"FAQ.md"`). + """ + : string.Empty; + + string scriptInstruction = includeScriptInstructions + ? "- Use `run_skill_script` to run referenced scripts, using the name exactly as listed." + : string.Empty; + + return new StringBuilder(promptTemplate) + .Replace(SkillsPlaceholder, sb.ToString().TrimEnd()) + .Replace(ResourceInstructionsPlaceholder, resourceInstruction) + .Replace(ScriptInstructionsPlaceholder, scriptInstruction) + .ToString(); + } + + private string LoadSkill(IList skills, string skillName) + { + if (string.IsNullOrWhiteSpace(skillName)) + { + return "Error: Skill name cannot be empty."; + } + + var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName); + if (skill == null) + { + return $"Error: Skill '{skillName}' not found."; + } + + LogSkillLoading(this._logger, skillName); + + return skill.Content; + } + + private async Task ReadSkillResourceAsync(IList skills, string skillName, string resourceName, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(skillName)) + { + return "Error: Skill name cannot be empty."; + } + + if (string.IsNullOrWhiteSpace(resourceName)) + { + return "Error: Resource name cannot be empty."; + } + + var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName); + if (skill == null) + { + return $"Error: Skill '{skillName}' not found."; + } + + var resource = skill.Resources?.FirstOrDefault(resource => resource.Name == resourceName); + if (resource is null) + { + return $"Error: Resource '{resourceName}' not found in skill '{skillName}'."; + } + + try + { + return await resource.ReadAsync(serviceProvider, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + LogResourceReadError(this._logger, skillName, resourceName, ex); + return $"Error: Failed to read resource '{resourceName}' from skill '{skillName}'."; + } + } + + private async Task RunSkillScriptAsync(IList skills, string skillName, string scriptName, IDictionary? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(skillName)) + { + return "Error: Skill name cannot be empty."; + } + + if (string.IsNullOrWhiteSpace(scriptName)) + { + return "Error: Script name cannot be empty."; + } + + var skill = skills?.FirstOrDefault(skill => skill.Frontmatter.Name == skillName); + if (skill == null) + { + return $"Error: Skill '{skillName}' not found."; + } + + var script = skill.Scripts?.FirstOrDefault(resource => resource.Name == scriptName); + if (script is null) + { + return $"Error: Script '{scriptName}' not found in skill '{skillName}'."; + } + + try + { + return await script.RunAsync(skill, new AIFunctionArguments(arguments) { Services = serviceProvider }, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + LogScriptExecutionError(this._logger, skillName, scriptName, ex); + return $"Error: Failed to execute script '{scriptName}' from skill '{skillName}'."; + } + } + + /// + /// Validates that a custom prompt template contains the required placeholder tokens. + /// + private static void ValidatePromptTemplate(string template, string paramName) + { + if (template.IndexOf(SkillsPlaceholder, StringComparison.Ordinal) < 0) + { + throw new ArgumentException( + $"The custom prompt template must contain the '{SkillsPlaceholder}' placeholder for the generated skills list.", + paramName); + } + + if (template.IndexOf(ResourceInstructionsPlaceholder, StringComparison.Ordinal) < 0) + { + throw new ArgumentException( + $"The custom prompt template must contain the '{ResourceInstructionsPlaceholder}' placeholder for resource instructions.", + paramName); + } + + if (template.IndexOf(ScriptInstructionsPlaceholder, StringComparison.Ordinal) < 0) + { + throw new ArgumentException( + $"The custom prompt template must contain the '{ScriptInstructionsPlaceholder}' placeholder for script instructions.", + paramName); + } + } + + [LoggerMessage(LogLevel.Information, "Loading skill: {SkillName}")] + private static partial void LogSkillLoading(ILogger logger, string skillName); + + [LoggerMessage(LogLevel.Error, "Failed to read resource '{ResourceName}' from skill '{SkillName}'")] + private static partial void LogResourceReadError(ILogger logger, string skillName, string resourceName, Exception exception); + + [LoggerMessage(LogLevel.Error, "Failed to execute script '{ScriptName}' from skill '{SkillName}'")] + private static partial void LogScriptExecutionError(ILogger logger, string skillName, string scriptName, Exception exception); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs new file mode 100644 index 0000000000..17d7f2d6f3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Fluent builder for constructing an backed by a composite source. +/// +/// +/// +/// var provider = new AgentSkillsProviderBuilder() +/// .UseFileSkills("/path/to/skills") +/// .Build(); +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentSkillsProviderBuilder +{ + private readonly List> _sourceFactories = []; + private AgentSkillsProviderOptions? _options; + private ILoggerFactory? _loggerFactory; + private AgentFileSkillScriptRunner? _scriptRunner; + private Func? _filter; + + /// + /// Adds a file-based skill source that discovers skills from a filesystem directory. + /// + /// Path to search for skills. + /// Optional options that control skill discovery behavior. + /// + /// Optional runner for file-based scripts. When provided, overrides the builder-level runner + /// set via . + /// + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseFileSkill(string skillPath, AgentFileSkillsSourceOptions? options = null, AgentFileSkillScriptRunner? scriptRunner = null) + { + return this.UseFileSkills([skillPath], options, scriptRunner); + } + + /// + /// Adds a file-based skill source that discovers skills from multiple filesystem directories. + /// + /// Paths to search for skills. + /// Optional options that control skill discovery behavior. + /// + /// Optional runner for file-based scripts. When provided, overrides the builder-level runner + /// set via . + /// + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseFileSkills(IEnumerable skillPaths, AgentFileSkillsSourceOptions? options = null, AgentFileSkillScriptRunner? scriptRunner = null) + { + this._sourceFactories.Add((builderScriptRunner, loggerFactory) => + { + var resolvedRunner = scriptRunner + ?? builderScriptRunner + ?? throw new InvalidOperationException($"File-based skill sources require a script runner. Call {nameof(this.UseFileScriptRunner)} or pass a runner to {nameof(this.UseFileSkill)}/{nameof(this.UseFileSkills)}."); + return new AgentFileSkillsSource(skillPaths, resolvedRunner, options, loggerFactory); + }); + return this; + } + + /// + /// Adds a custom skill source. + /// + /// The custom skill source. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseSource(AgentSkillsSource source) + { + _ = Throw.IfNull(source); + this._sourceFactories.Add((_, _) => source); + return this; + } + + /// + /// Sets a custom system prompt template. + /// + /// The prompt template with {skills} placeholder for the skills list, + /// {resource_instructions} for optional resource instructions, + /// and {script_instructions} for optional script instructions. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UsePromptTemplate(string promptTemplate) + { + this.GetOrCreateOptions().SkillsInstructionPrompt = promptTemplate; + return this; + } + + /// + /// Enables or disables the script approval gate. + /// + /// Whether script execution requires approval. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseScriptApproval(bool enabled = true) + { + this.GetOrCreateOptions().ScriptApproval = enabled; + return this; + } + + /// + /// Sets the runner for file-based skill scripts. + /// + /// The delegate that runs file-based scripts. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseFileScriptRunner(AgentFileSkillScriptRunner runner) + { + this._scriptRunner = Throw.IfNull(runner); + return this; + } + + /// + /// Sets the logger factory. + /// + /// The logger factory. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseLoggerFactory(ILoggerFactory loggerFactory) + { + this._loggerFactory = loggerFactory; + return this; + } + + /// + /// Sets a filter predicate that controls which skills are included. + /// + /// + /// Skills for which the predicate returns are kept; + /// others are excluded. Only one filter is supported; calling this method + /// again replaces any previously set filter. + /// + /// A predicate that determines which skills to include. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseFilter(Func predicate) + { + _ = Throw.IfNull(predicate); + this._filter = predicate; + return this; + } + + /// + /// Configures the using the provided delegate. + /// + /// A delegate to configure the options. + /// This builder instance for chaining. + public AgentSkillsProviderBuilder UseOptions(Action configure) + { + _ = Throw.IfNull(configure); + configure(this.GetOrCreateOptions()); + return this; + } + + /// + /// Builds the . + /// + /// A configured . + public AgentSkillsProvider Build() + { + var resolvedSources = new List(this._sourceFactories.Count); + foreach (var factory in this._sourceFactories) + { + resolvedSources.Add(factory(this._scriptRunner, this._loggerFactory)); + } + + AgentSkillsSource source; + if (resolvedSources.Count == 1) + { + source = resolvedSources[0]; + } + else + { + source = new AggregatingAgentSkillsSource(resolvedSources); + } + + // Apply user-specified filter, then dedup. + if (this._filter != null) + { + source = new FilteringAgentSkillsSource(source, this._filter, this._loggerFactory); + } + + source = new DeduplicatingAgentSkillsSource(source, this._loggerFactory); + + return new AgentSkillsProvider(source, this._options, this._loggerFactory); + } + + private AgentSkillsProviderOptions GetOrCreateOptions() + { + return this._options ??= new AgentSkillsProviderOptions(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderOptions.cs new file mode 100644 index 0000000000..2f89ebfda6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderOptions.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Configuration options for . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentSkillsProviderOptions +{ + /// + /// Gets or sets a custom system prompt template for advertising skills. + /// The template must contain {skills} as the placeholder for the generated skills list, + /// {resource_instructions} for resource instructions, + /// and {script_instructions} for script instructions. + /// When , a default template is used. + /// + public string? SkillsInstructionPrompt { get; set; } + + /// + /// Gets or sets a value indicating whether script execution requires approval. + /// When , script execution is blocked until approved. + /// Defaults to . + /// + public bool ScriptApproval { get; set; } + + /// + /// Gets or sets a value indicating whether caching of tools and instructions is disabled. + /// When (the default), the provider caches the tools and instructions + /// after the first build and returns the cached instance on subsequent calls. + /// Set to to rebuild tools and instructions on every invocation. + /// + public bool DisableCaching { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsSource.cs new file mode 100644 index 0000000000..6a72d0c01a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsSource.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Abstract base class for skill sources. A skill source provides skills from a specific origin +/// (filesystem, remote server, database, in-memory, etc.). +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public abstract class AgentSkillsSource +{ + /// + /// Gets the skills provided by this source. + /// + /// Cancellation token. + /// A collection of skills from this source. + public abstract Task> GetSkillsAsync(CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AggregatingAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AggregatingAgentSkillsSource.cs new file mode 100644 index 0000000000..7dc468742f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AggregatingAgentSkillsSource.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A skill source that aggregates multiple child sources, preserving their registration order. +/// +/// +/// Skills from each child source are returned in the order the sources were registered, +/// with each source's skills appended sequentially. No deduplication or filtering is applied. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal sealed class AggregatingAgentSkillsSource : AgentSkillsSource +{ + private readonly IEnumerable _sources; + + /// + /// Initializes a new instance of the class. + /// + /// The child sources to aggregate. + public AggregatingAgentSkillsSource(IEnumerable sources) + { + this._sources = Throw.IfNull(sources); + } + + /// + public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + var allSkills = new List(); + foreach (var source in this._sources) + { + var skills = await source.GetSkillsAsync(cancellationToken).ConfigureAwait(false); + allSkills.AddRange(skills); + } + + return allSkills; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DeduplicatingAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DeduplicatingAgentSkillsSource.cs new file mode 100644 index 0000000000..bf943daae5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DeduplicatingAgentSkillsSource.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// A skill source decorator that removes duplicate skills by name, keeping only the first occurrence. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal sealed partial class DeduplicatingAgentSkillsSource : DelegatingAgentSkillsSource +{ + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The inner source to deduplicate. + /// Optional logger factory. + public DeduplicatingAgentSkillsSource(AgentSkillsSource innerSource, ILoggerFactory? loggerFactory = null) + : base(innerSource) + { + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + } + + /// + public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + var allSkills = await this.InnerSource.GetSkillsAsync(cancellationToken).ConfigureAwait(false); + + var deduplicated = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var skill in allSkills) + { + if (seen.Add(skill.Frontmatter.Name)) + { + deduplicated.Add(skill); + } + else + { + LogDuplicateSkillName(this._logger, skill.Frontmatter.Name); + } + } + + return deduplicated; + } + + [LoggerMessage(LogLevel.Warning, "Duplicate skill name '{SkillName}': subsequent skill skipped in favor of first occurrence")] + private static partial void LogDuplicateSkillName(ILogger logger, string skillName); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DelegatingAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DelegatingAgentSkillsSource.cs new file mode 100644 index 0000000000..920ad0428b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/DelegatingAgentSkillsSource.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Provides an abstract base class for skill sources that delegate operations to an inner source +/// while allowing for extensibility and customization. +/// +/// +/// implements the decorator pattern for , +/// enabling the creation of source pipelines where each layer can add functionality (caching, deduplication, +/// filtering, etc.) while delegating core operations to an underlying source. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal abstract class DelegatingAgentSkillsSource : AgentSkillsSource +{ + /// + /// Initializes a new instance of the class with the specified inner source. + /// + /// The underlying skill source that will handle the core operations. + protected DelegatingAgentSkillsSource(AgentSkillsSource innerSource) + { + this.InnerSource = Throw.IfNull(innerSource); + } + + /// + /// Gets the inner skill source that receives delegated operations. + /// + protected AgentSkillsSource InnerSource { get; } + + /// + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + => this.InnerSource.GetSkillsAsync(cancellationToken); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs new file mode 100644 index 0000000000..2bd26acce2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Decorators/FilteringAgentSkillsSource.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A skill source decorator that filters skills using a caller-supplied predicate. +/// +/// +/// Skills for which the predicate returns are included in the result; +/// skills for which it returns are excluded and logged at debug level. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal sealed partial class FilteringAgentSkillsSource : DelegatingAgentSkillsSource +{ + private readonly Func _predicate; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The inner source whose skills will be filtered. + /// + /// A predicate that determines which skills to include. Skills for which the predicate + /// returns are kept; others are excluded. + /// + /// Optional logger factory. + public FilteringAgentSkillsSource( + AgentSkillsSource innerSource, + Func predicate, + ILoggerFactory? loggerFactory = null) + : base(innerSource) + { + this._predicate = Throw.IfNull(predicate); + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + } + + /// + public override async Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + var allSkills = await this.InnerSource.GetSkillsAsync(cancellationToken).ConfigureAwait(false); + + var filtered = new List(); + foreach (var skill in allSkills) + { + if (this._predicate(skill)) + { + filtered.Add(skill); + } + else + { + LogSkillFiltered(this._logger, skill.Frontmatter.Name); + } + } + + return filtered; + } + + [LoggerMessage(LogLevel.Debug, "Skill '{SkillName}' excluded by filter predicate")] + private static partial void LogSkillFiltered(ILogger logger, string skillName); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs new file mode 100644 index 0000000000..4bb62e99a8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// An discovered from a filesystem directory backed by a SKILL.md file. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentFileSkill : AgentSkill +{ + private readonly IReadOnlyList _resources; + private readonly IReadOnlyList _scripts; + + /// + /// Initializes a new instance of the class. + /// + /// The parsed frontmatter metadata for this skill. + /// The full raw SKILL.md file content including YAML frontmatter. + /// Absolute path to the directory containing this skill. + /// Resources discovered for this skill. + /// Scripts discovered for this skill. + internal AgentFileSkill( + AgentSkillFrontmatter frontmatter, + string content, + string path, + IReadOnlyList? resources = null, + IReadOnlyList? scripts = null) + { + this.Frontmatter = Throw.IfNull(frontmatter); + this.Content = Throw.IfNull(content); + this.Path = Throw.IfNullOrWhitespace(path); + this._resources = resources ?? []; + this._scripts = scripts ?? []; + } + + /// + public override AgentSkillFrontmatter Frontmatter { get; } + + /// + public override string Content { get; } + + /// + /// Gets the directory path where the skill was discovered. + /// + public string Path { get; } + + /// + public override IReadOnlyList Resources => this._resources; + + /// + public override IReadOnlyList Scripts => this._scripts; +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillResource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillResource.cs new file mode 100644 index 0000000000..9ba5b7e24a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillResource.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A file-path-backed skill resource. Reads content from a file on disk relative to the skill directory. +/// +internal sealed class AgentFileSkillResource : AgentSkillResource +{ + /// + /// Initializes a new instance of the class. + /// + /// The resource name (relative path within the skill directory). + /// The absolute file path to the resource. + public AgentFileSkillResource(string name, string fullPath) + : base(name) + { + this.FullPath = Throw.IfNullOrWhitespace(fullPath); + } + + /// + /// Gets the absolute file path to the resource. + /// + public string FullPath { get; } + + /// + public override async Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) + { +#if NET8_0_OR_GREATER + return await File.ReadAllTextAsync(this.FullPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false); +#else + using var reader = new StreamReader(this.FullPath, Encoding.UTF8); + return await reader.ReadToEndAsync().ConfigureAwait(false); +#endif + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs new file mode 100644 index 0000000000..116847126f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// A file-path-backed skill script. Represents a script file on disk that requires an external runner to run. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentFileSkillScript : AgentSkillScript +{ + private readonly AgentFileSkillScriptRunner? _runner; + + /// + /// Initializes a new instance of the class. + /// + /// The script name. + /// The absolute file path to the script. + /// Optional external runner for running the script. An is thrown from if no runner is provided. + internal AgentFileSkillScript(string name, string fullPath, AgentFileSkillScriptRunner? runner = null) + : base(name) + { + this.FullPath = Throw.IfNullOrWhitespace(fullPath); + this._runner = runner; + } + + /// + /// Gets the absolute file path to the script. + /// + public string FullPath { get; } + + /// + public override async Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default) + { + if (skill is not AgentFileSkill fileSkill) + { + throw new InvalidOperationException($"File-based script '{this.Name}' requires an {nameof(AgentFileSkill)} but received '{skill.GetType().Name}'."); + } + + if (this._runner is null) + { + throw new InvalidOperationException( + $"Script '{this.Name}' cannot be executed because no {nameof(AgentFileSkillScriptRunner)} was provided. " + + $"Supply a script runner when constructing {nameof(AgentFileSkillsSource)} to enable script execution."); + } + + return await this._runner(fileSkill, this, arguments, cancellationToken).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs new file mode 100644 index 0000000000..c19d19e056 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Delegate for running file-based skill scripts. +/// +/// +/// Implementations determine the execution strategy (e.g., local subprocess, hosted code execution environment). +/// +/// The skill that owns the script. +/// The file-based script to run. +/// Optional arguments for the script, provided by the agent/LLM. +/// Cancellation token. +/// The script execution result. +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public delegate Task AgentFileSkillScriptRunner( + AgentFileSkill skill, + AgentFileSkillScript script, + AIFunctionArguments arguments, + CancellationToken cancellationToken); diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs similarity index 51% rename from dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs rename to dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs index 18fa87999a..c6dc3bc629 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs @@ -2,151 +2,135 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI; /// -/// Discovers, parses, and validates SKILL.md files from filesystem directories. +/// A skill source that discovers skills from filesystem directories containing SKILL.md files. /// /// -/// Searches directories recursively (up to levels) for SKILL.md files. -/// Each file is validated for YAML frontmatter. Resource files are discovered by scanning the skill +/// Searches directories recursively (up to 2 levels deep) for SKILL.md files. +/// Each file is validated for YAML frontmatter. Resource and script files are discovered by scanning the skill /// directory for files with matching extensions. Invalid resources are skipped with logged warnings. -/// Resource paths are checked against path traversal and symlink escape attacks. +/// Resource and script paths are checked against path traversal and symlink escape attacks. /// -internal sealed partial class FileAgentSkillLoader +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +internal sealed partial class AgentFileSkillsSource : AgentSkillsSource { private const string SkillFileName = "SKILL.md"; private const int MaxSearchDepth = 2; - private const int MaxNameLength = 64; - private const int MaxDescriptionLength = 1024; + + private static readonly string[] s_defaultScriptExtensions = [".py", ".js", ".sh", ".ps1", ".cs", ".csx"]; + private static readonly string[] s_defaultResourceExtensions = [".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt"]; // Matches YAML frontmatter delimited by "---" lines. Group 1 = content between delimiters. // Multiline makes ^/$ match line boundaries; Singleline makes . match newlines across the block. // The \uFEFF? prefix allows an optional UTF-8 BOM that some editors prepend. - // Example: "---\nname: foo\n---\nBody" → Group 1: "name: foo\n" private static readonly Regex s_frontmatterRegex = new(@"\A\uFEFF?^---\s*$(.+?)^---\s*$", RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled, TimeSpan.FromSeconds(5)); - // Matches YAML "key: value" lines. Group 1 = key, Group 2 = quoted value, Group 3 = unquoted value. + // Matches top-level YAML "key: value" lines. Group 1 = key (supports hyphens for keys like allowed-tools), + // Group 2 = quoted value, Group 3 = unquoted value. // Accepts single or double quotes; the lazy quantifier trims trailing whitespace on unquoted values. - // Examples: "name: foo" → (name, _, foo), "name: 'foo bar'" → (name, foo bar, _), - // "description: \"A skill\"" → (description, A skill, _) - private static readonly Regex s_yamlKeyValueRegex = new(@"^\s*(\w+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5)); + private static readonly Regex s_yamlKeyValueRegex = new(@"^([\w-]+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5)); - // Validates skill names: lowercase letters, numbers, and hyphens only; - // must not start or end with a hyphen; must not contain consecutive hyphens. - // Examples: "my-skill" ✓, "skill123" ✓, "-bad" ✗, "bad-" ✗, "Bad" ✗, "my--skill" ✗ - private static readonly Regex s_validNameRegex = new("^[a-z0-9]([a-z0-9]*-[a-z0-9])*[a-z0-9]*$", RegexOptions.Compiled); + // Matches a "metadata:" line followed by indented sub-key/value pairs. + // Group 1 captures the entire indented block beneath the metadata key. + private static readonly Regex s_yamlMetadataBlockRegex = new(@"^metadata\s*:\s*$\n((?:[ \t]+\S.*\n?)+)", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5)); - private readonly ILogger _logger; + // Matches indented YAML "key: value" lines within a metadata block. + // Group 1 = key (supports hyphens), Group 2 = quoted value, Group 3 = unquoted value. + private static readonly Regex s_yamlIndentedKeyValueRegex = new(@"^\s+([\w-]+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5)); + + private readonly IEnumerable _skillPaths; private readonly HashSet _allowedResourceExtensions; + private readonly HashSet _allowedScriptExtensions; + private readonly AgentFileSkillScriptRunner? _scriptRunner; + private readonly ILogger _logger; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - /// The logger instance. - /// File extensions to recognize as skill resources. When , defaults are used. - internal FileAgentSkillLoader(ILogger logger, IEnumerable? allowedResourceExtensions = null) + /// Path to search for skills. + /// Optional runner for file-based scripts. Required only when skills contain scripts. + /// Optional options that control skill discovery behavior. + /// Optional logger factory. + public AgentFileSkillsSource( + string skillPath, + AgentFileSkillScriptRunner? scriptRunner = null, + AgentFileSkillsSourceOptions? options = null, + ILoggerFactory? loggerFactory = null) + : this([skillPath], scriptRunner, options, loggerFactory) { - this._logger = logger; - - ValidateExtensions(allowedResourceExtensions); - - this._allowedResourceExtensions = new HashSet( - allowedResourceExtensions ?? [".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt"], - StringComparer.OrdinalIgnoreCase); } /// - /// Discovers skill directories and loads valid skills from them. + /// Initializes a new instance of the class. /// - /// Paths to search for skills. Each path can point to an individual skill folder or a parent folder. - /// A dictionary of loaded skills keyed by skill name. - internal Dictionary DiscoverAndLoadSkills(IEnumerable skillPaths) + /// Paths to search for skills. + /// Optional runner for file-based scripts. Required only when skills contain scripts. + /// Optional options that control skill discovery behavior. + /// Optional logger factory. + public AgentFileSkillsSource( + IEnumerable skillPaths, + AgentFileSkillScriptRunner? scriptRunner = null, + AgentFileSkillsSourceOptions? options = null, + ILoggerFactory? loggerFactory = null) { - var skills = new Dictionary(StringComparer.OrdinalIgnoreCase); + this._skillPaths = Throw.IfNull(skillPaths); - var discoveredPaths = DiscoverSkillDirectories(skillPaths); + var resolvedOptions = options ?? new AgentFileSkillsSourceOptions(); + + ValidateExtensions(resolvedOptions.AllowedResourceExtensions); + ValidateExtensions(resolvedOptions.AllowedScriptExtensions); + + this._allowedResourceExtensions = new HashSet( + resolvedOptions.AllowedResourceExtensions ?? s_defaultResourceExtensions, + StringComparer.OrdinalIgnoreCase); + + this._allowedScriptExtensions = new HashSet( + resolvedOptions.AllowedScriptExtensions ?? s_defaultScriptExtensions, + StringComparer.OrdinalIgnoreCase); + + this._scriptRunner = scriptRunner; + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + } + + /// + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + var discoveredPaths = DiscoverSkillDirectories(this._skillPaths); LogSkillsDiscovered(this._logger, discoveredPaths.Count); + var skills = new List(); + foreach (string skillPath in discoveredPaths) { - FileAgentSkill? skill = this.ParseSkillFile(skillPath); + AgentFileSkill? skill = this.ParseSkillDirectory(skillPath); if (skill is null) { continue; } - if (skills.TryGetValue(skill.Frontmatter.Name, out FileAgentSkill? existing)) - { - LogDuplicateSkillName(this._logger, skill.Frontmatter.Name, skillPath, existing.SourcePath); - - // Skip duplicate skill names, keeping the first one found. - continue; - } - - skills[skill.Frontmatter.Name] = skill; + skills.Add(skill); LogSkillLoaded(this._logger, skill.Frontmatter.Name); } LogSkillsLoadedTotal(this._logger, skills.Count); - return skills; - } - - /// - /// Reads a resource file from disk with path traversal and symlink guards. - /// - /// The skill that owns the resource. - /// Relative path of the resource within the skill directory. - /// Cancellation token. - /// The UTF-8 text content of the resource file. - /// - /// The resource is not registered, resolves outside the skill directory, or does not exist. - /// - internal async Task ReadSkillResourceAsync(FileAgentSkill skill, string resourceName, CancellationToken cancellationToken = default) - { - resourceName = NormalizeResourcePath(resourceName); - - if (!skill.ResourceNames.Any(r => r.Equals(resourceName, StringComparison.OrdinalIgnoreCase))) - { - throw new InvalidOperationException($"Resource '{resourceName}' not found in skill '{skill.Frontmatter.Name}'."); - } - - string fullPath = Path.GetFullPath(Path.Combine(skill.SourcePath, resourceName)); - string normalizedSourcePath = Path.GetFullPath(skill.SourcePath) + Path.DirectorySeparatorChar; - - if (!IsPathWithinDirectory(fullPath, normalizedSourcePath)) - { - throw new InvalidOperationException($"Resource file '{resourceName}' references a path outside the skill directory."); - } - - if (!File.Exists(fullPath)) - { - throw new InvalidOperationException($"Resource file '{resourceName}' not found in skill '{skill.Frontmatter.Name}'."); - } - - if (HasSymlinkInPath(fullPath, normalizedSourcePath)) - { - throw new InvalidOperationException($"Resource file '{resourceName}' is a symlink that resolves outside the skill directory."); - } - - LogResourceReading(this._logger, resourceName, skill.Frontmatter.Name); - -#if NET - return await File.ReadAllTextAsync(fullPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false); -#else - return await Task.FromResult(File.ReadAllText(fullPath, Encoding.UTF8)).ConfigureAwait(false); -#endif + return Task.FromResult(skills as IList); } private static List DiscoverSkillDirectories(IEnumerable skillPaths) @@ -185,30 +169,30 @@ internal sealed partial class FileAgentSkillLoader } } - private FileAgentSkill? ParseSkillFile(string skillDirectoryFullPath) + private AgentFileSkill? ParseSkillDirectory(string skillDirectoryFullPath) { string skillFilePath = Path.Combine(skillDirectoryFullPath, SkillFileName); - string content = File.ReadAllText(skillFilePath, Encoding.UTF8); - if (!this.TryParseSkillDocument(content, skillFilePath, out SkillFrontmatter frontmatter, out string body)) + if (!this.TryParseFrontmatter(content, skillFilePath, out AgentSkillFrontmatter? frontmatter)) { return null; } - List resourceNames = this.DiscoverResourceFiles(skillDirectoryFullPath, frontmatter.Name); + var resources = this.DiscoverResourceFiles(skillDirectoryFullPath, frontmatter.Name); + var scripts = this.DiscoverScriptFiles(skillDirectoryFullPath, frontmatter.Name); - return new FileAgentSkill( + return new AgentFileSkill( frontmatter: frontmatter, - body: body, - sourcePath: skillDirectoryFullPath, - resourceNames: resourceNames); + content: content, + path: skillDirectoryFullPath, + resources: resources, + scripts: scripts); } - private bool TryParseSkillDocument(string content, string skillFilePath, out SkillFrontmatter frontmatter, out string body) + private bool TryParseFrontmatter(string content, string skillFilePath, [NotNullWhen(true)] out AgentSkillFrontmatter? frontmatter) { - frontmatter = null!; - body = null!; + frontmatter = null; Match match = s_frontmatterRegex.Match(content); if (!match.Success) @@ -217,10 +201,13 @@ internal sealed partial class FileAgentSkillLoader return false; } + string yamlContent = match.Groups[1].Value.Trim(); + string? name = null; string? description = null; - - string yamlContent = match.Groups[1].Value.Trim(); + string? license = null; + string? compatibility = null; + string? allowedTools = null; foreach (Match kvMatch in s_yamlKeyValueRegex.Matches(yamlContent)) { @@ -235,50 +222,62 @@ internal sealed partial class FileAgentSkillLoader { description = value; } + else if (string.Equals(key, "license", StringComparison.OrdinalIgnoreCase)) + { + license = value; + } + else if (string.Equals(key, "compatibility", StringComparison.OrdinalIgnoreCase)) + { + compatibility = value; + } + else if (string.Equals(key, "allowed-tools", StringComparison.OrdinalIgnoreCase)) + { + allowedTools = value; + } } - if (string.IsNullOrWhiteSpace(name)) + // Parse metadata block (indented key-value pairs under "metadata:"). + AdditionalPropertiesDictionary? metadata = null; + Match metadataMatch = s_yamlMetadataBlockRegex.Match(yamlContent); + if (metadataMatch.Success) { - LogMissingFrontmatterField(this._logger, skillFilePath, "name"); + metadata = []; + foreach (Match kvMatch in s_yamlIndentedKeyValueRegex.Matches(metadataMatch.Groups[1].Value)) + { + metadata[kvMatch.Groups[1].Value] = kvMatch.Groups[2].Success ? kvMatch.Groups[2].Value : kvMatch.Groups[3].Value; + } + } + + if (!AgentSkillFrontmatter.ValidateName(name, out string? validationReason) || + !AgentSkillFrontmatter.ValidateDescription(description, out validationReason)) + { + LogInvalidFieldValue(this._logger, skillFilePath, "frontmatter", validationReason); return false; } - if (name.Length > MaxNameLength || !s_validNameRegex.IsMatch(name)) + frontmatter = new AgentSkillFrontmatter(name!, description!, compatibility) { - LogInvalidFieldValue(this._logger, skillFilePath, "name", $"Must be {MaxNameLength} characters or fewer, using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen or contain consecutive hyphens."); - return false; - } + License = license, + AllowedTools = allowedTools, + Metadata = metadata, + }; // skillFilePath is e.g. "/skills/my-skill/SKILL.md". // GetDirectoryName strips the filename → "/skills/my-skill". // GetFileName then extracts the last segment → "my-skill". // This gives us the skill's parent directory name to validate against the frontmatter name. string directoryName = Path.GetFileName(Path.GetDirectoryName(skillFilePath)) ?? string.Empty; - if (!string.Equals(name, directoryName, StringComparison.Ordinal)) + if (!string.Equals(frontmatter.Name, directoryName, StringComparison.Ordinal)) { if (this._logger.IsEnabled(LogLevel.Error)) { - LogNameDirectoryMismatch(this._logger, SanitizePathForLog(skillFilePath), name, SanitizePathForLog(directoryName)); + LogNameDirectoryMismatch(this._logger, SanitizePathForLog(skillFilePath), frontmatter.Name, SanitizePathForLog(directoryName)); } + frontmatter = null; return false; } - if (string.IsNullOrWhiteSpace(description)) - { - LogMissingFrontmatterField(this._logger, skillFilePath, "description"); - return false; - } - - if (description.Length > MaxDescriptionLength) - { - LogInvalidFieldValue(this._logger, skillFilePath, "description", $"Must be {MaxDescriptionLength} characters or fewer."); - return false; - } - - frontmatter = new SkillFrontmatter(name, description); - body = content.Substring(match.Index + match.Length).TrimStart(); - return true; } @@ -287,15 +286,15 @@ internal sealed partial class FileAgentSkillLoader /// /// /// Recursively walks and collects files whose extension - /// matches , excluding SKILL.md itself. Each candidate + /// matches the allowed set, excluding SKILL.md itself. Each candidate /// is validated against path-traversal and symlink-escape checks; unsafe files are skipped with /// a warning. /// - private List DiscoverResourceFiles(string skillDirectoryFullPath, string skillName) + private List DiscoverResourceFiles(string skillDirectoryFullPath, string skillName) { string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar; - var resources = new List(); + var resources = new List(); #if NET var enumerationOptions = new EnumerationOptions @@ -326,21 +325,21 @@ internal sealed partial class FileAgentSkillLoader { LogResourceSkippedExtension(this._logger, skillName, SanitizePathForLog(filePath), extension); } + continue; } // Normalize the enumerated path to guard against non-canonical forms - // (redundant separators, 8.3 short names, etc.) that would produce - // malformed relative resource names. string resolvedFilePath = Path.GetFullPath(filePath); // Path containment check - if (!IsPathWithinDirectory(resolvedFilePath, normalizedSkillDirectoryFullPath)) + if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase)) { if (this._logger.IsEnabled(LogLevel.Warning)) { LogResourcePathTraversal(this._logger, skillName, SanitizePathForLog(filePath)); } + continue; } @@ -351,30 +350,86 @@ internal sealed partial class FileAgentSkillLoader { LogResourceSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath)); } + continue; } // Compute relative path and normalize to forward slashes - string relativePath = resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length); - resources.Add(NormalizeResourcePath(relativePath)); + string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length)); + resources.Add(new AgentFileSkillResource(relativePath, resolvedFilePath)); } return resources; } /// - /// Checks that is under , - /// guarding against path traversal attacks. + /// Scans a skill directory for script files matching the configured extensions. /// - private static bool IsPathWithinDirectory(string fullPath, string normalizedDirectoryPath) + /// + /// Recursively walks the skill directory and collects files whose extension + /// matches the allowed set. Each candidate is validated against path-traversal + /// and symlink-escape checks; unsafe files are skipped with a warning. + /// + private List DiscoverScriptFiles(string skillDirectoryFullPath, string skillName) { - return fullPath.StartsWith(normalizedDirectoryPath, StringComparison.OrdinalIgnoreCase); + string normalizedSkillDirectoryFullPath = skillDirectoryFullPath + Path.DirectorySeparatorChar; + var scripts = new List(); + +#if NET + var enumerationOptions = new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = true, + AttributesToSkip = FileAttributes.ReparsePoint, + }; + + foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", enumerationOptions)) +#else + foreach (string filePath in Directory.EnumerateFiles(skillDirectoryFullPath, "*", SearchOption.AllDirectories)) +#endif + { + // Filter by extension + string extension = Path.GetExtension(filePath); + if (string.IsNullOrEmpty(extension) || !this._allowedScriptExtensions.Contains(extension)) + { + continue; + } + + // Normalize the enumerated path to guard against non-canonical forms + string resolvedFilePath = Path.GetFullPath(filePath); + + // Path containment check + if (!resolvedFilePath.StartsWith(normalizedSkillDirectoryFullPath, StringComparison.OrdinalIgnoreCase)) + { + if (this._logger.IsEnabled(LogLevel.Warning)) + { + LogScriptPathTraversal(this._logger, skillName, SanitizePathForLog(filePath)); + } + + continue; + } + + // Symlink check + if (HasSymlinkInPath(resolvedFilePath, normalizedSkillDirectoryFullPath)) + { + if (this._logger.IsEnabled(LogLevel.Warning)) + { + LogScriptSymlinkEscape(this._logger, skillName, SanitizePathForLog(filePath)); + } + + continue; + } + + // Compute relative path and normalize to forward slashes + string relativePath = NormalizePath(resolvedFilePath.Substring(normalizedSkillDirectoryFullPath.Length)); + scripts.Add(new AgentFileSkillScript(relativePath, resolvedFilePath, this._scriptRunner)); + } + + return scripts; } /// - /// Checks whether any segment in (relative to - /// ) is a symlink (reparse point). - /// Uses which is available on all target frameworks. + /// Checks whether any segment in the path (relative to the directory) is a symlink. /// private static bool HasSymlinkInPath(string fullPath, string normalizedDirectoryPath) { @@ -399,11 +454,10 @@ internal sealed partial class FileAgentSkillLoader } /// - /// Normalizes a relative resource path by trimming a leading ./ prefix and replacing - /// backslashes with forward slashes so that ./refs/doc.md and refs/doc.md are - /// treated as the same resource. + /// Normalizes a relative path by replacing backslashes with forward slashes + /// and trimming a leading "./" prefix. /// - private static string NormalizeResourcePath(string path) + private static string NormalizePath(string path) { if (path.IndexOf('\\') >= 0) { @@ -419,8 +473,7 @@ internal sealed partial class FileAgentSkillLoader } /// - /// Replaces control characters in a file path with '?' to prevent log injection - /// via crafted filenames (e.g., filenames containing newlines on Linux). + /// Replaces control characters in a file path with '?' to prevent log injection. /// private static string SanitizePathForLog(string path) { @@ -449,7 +502,7 @@ internal sealed partial class FileAgentSkillLoader if (string.IsNullOrWhiteSpace(ext) || !ext.StartsWith(".", StringComparison.Ordinal)) { #pragma warning disable CA2208 // Instantiate argument exceptions correctly - throw new ArgumentException($"Each extension must start with '.'. Invalid value: '{ext}'", nameof(FileAgentSkillsProviderOptions.AllowedResourceExtensions)); + throw new ArgumentException($"Each extension must start with '.'. Invalid value: '{ext}'", "allowedResourceExtensions"); #pragma warning restore CA2208 // Instantiate argument exceptions correctly } } @@ -467,9 +520,6 @@ internal sealed partial class FileAgentSkillLoader [LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' does not contain valid YAML frontmatter delimited by '---'")] private static partial void LogInvalidFrontmatter(ILogger logger, string skillFilePath); - [LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' is missing a '{FieldName}' field in frontmatter")] - private static partial void LogMissingFrontmatterField(ILogger logger, string skillFilePath, string fieldName); - [LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' has an invalid '{FieldName}' value: {Reason}")] private static partial void LogInvalidFieldValue(ILogger logger, string skillFilePath, string fieldName, string reason); @@ -479,15 +529,15 @@ internal sealed partial class FileAgentSkillLoader [LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' references a path outside the skill directory")] private static partial void LogResourcePathTraversal(ILogger logger, string skillName, string resourcePath); - [LoggerMessage(LogLevel.Warning, "Duplicate skill name '{SkillName}': skill from '{NewPath}' skipped in favor of existing skill from '{ExistingPath}'")] - private static partial void LogDuplicateSkillName(ILogger logger, string skillName, string newPath, string existingPath); - [LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' is a symlink that resolves outside the skill directory")] private static partial void LogResourceSymlinkEscape(ILogger logger, string skillName, string resourcePath); - [LoggerMessage(LogLevel.Information, "Reading resource '{FileName}' from skill '{SkillName}'")] - private static partial void LogResourceReading(ILogger logger, string fileName, string skillName); - [LoggerMessage(LogLevel.Debug, "Skipping file '{FilePath}' in skill '{SkillName}': extension '{Extension}' is not in the allowed list")] private static partial void LogResourceSkippedExtension(ILogger logger, string skillName, string filePath, string extension); + + [LoggerMessage(LogLevel.Warning, "Skipping script in skill '{SkillName}': '{ScriptPath}' references a path outside the skill directory")] + private static partial void LogScriptPathTraversal(ILogger logger, string skillName, string scriptPath); + + [LoggerMessage(LogLevel.Warning, "Skipping script in skill '{SkillName}': '{ScriptPath}' is a symlink that resolves outside the skill directory")] + private static partial void LogScriptSymlinkEscape(ILogger logger, string skillName, string scriptPath); } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs new file mode 100644 index 0000000000..edaec327fa --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Configuration options for file-based skill sources. +/// +/// +/// Use this class to configure file-based skill discovery without relying on +/// positional constructor or method parameters. New options can be added here +/// without breaking existing callers. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentFileSkillsSourceOptions +{ + /// + /// Gets or sets the allowed file extensions for skill resources. + /// When , defaults to .md, .json, .yaml, + /// .yml, .csv, .xml, .txt. + /// + public IEnumerable? AllowedResourceExtensions { get; set; } + + /// + /// Gets or sets the allowed file extensions for skill scripts. + /// When , defaults to .py, .js, .sh, + /// .ps1, .cs, .csx. + /// + public IEnumerable? AllowedScriptExtensions { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs deleted file mode 100644 index f28bad3ab0..0000000000 --- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI; - -/// -/// Represents a loaded Agent Skill discovered from a filesystem directory. -/// -/// -/// Each skill is backed by a SKILL.md file containing YAML frontmatter (name and description) -/// and a markdown body with instructions. Resource files referenced in the body are validated at -/// discovery time and read from disk on demand. -/// -internal sealed class FileAgentSkill -{ - /// - /// Initializes a new instance of the class. - /// - /// Parsed YAML frontmatter (name and description). - /// The SKILL.md content after the closing --- delimiter. - /// Absolute path to the directory containing this skill. - /// Relative paths of resource files referenced in the skill body. - public FileAgentSkill( - SkillFrontmatter frontmatter, - string body, - string sourcePath, - IReadOnlyList? resourceNames = null) - { - this.Frontmatter = Throw.IfNull(frontmatter); - this.Body = Throw.IfNull(body); - this.SourcePath = Throw.IfNullOrWhitespace(sourcePath); - this.ResourceNames = resourceNames ?? []; - } - - /// - /// Gets the parsed YAML frontmatter (name and description). - /// - public SkillFrontmatter Frontmatter { get; } - - /// - /// Gets the SKILL.md body content (without the YAML frontmatter). - /// - public string Body { get; } - - /// - /// Gets the directory path where the skill was discovered. - /// - public string SourcePath { get; } - - /// - /// Gets the relative paths of resource files referenced in the skill body (e.g., "references/FAQ.md"). - /// - public IReadOnlyList ResourceNames { get; } -} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs deleted file mode 100644 index 460faced70..0000000000 --- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs +++ /dev/null @@ -1,222 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Security; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Shared.DiagnosticIds; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI; - -/// -/// An that discovers and exposes Agent Skills from filesystem directories. -/// -/// -/// -/// This provider implements the progressive disclosure pattern from the -/// Agent Skills specification: -/// -/// -/// Advertise — skill names and descriptions are injected into the system prompt (~100 tokens per skill). -/// Load — the full SKILL.md body is returned via the load_skill tool. -/// Read resources — supplementary files are read from disk on demand via the read_skill_resource tool. -/// -/// -/// Skills are discovered by searching the configured directories for SKILL.md files. -/// Referenced resources are validated at initialization; invalid skills are excluded and logged. -/// -/// -/// Security: this provider only reads static content. Skill metadata is XML-escaped -/// before prompt embedding, and resource reads are guarded against path traversal and symlink escape. -/// Only use skills from trusted sources. -/// -/// -[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public sealed partial class FileAgentSkillsProvider : AIContextProvider -{ - private const string DefaultSkillsInstructionPrompt = - """ - You have access to skills containing domain-specific knowledge and capabilities. - Each skill provides specialized instructions, reference documents, and assets for specific tasks. - - - {0} - - - When a task aligns with a skill's domain: - 1. Use `load_skill` to retrieve the skill's instructions - 2. Follow the provided guidance - 3. Use `read_skill_resource` to read any references or other files mentioned by the skill - - Only load what is needed, when it is needed. - """; - - private readonly Dictionary _skills; - private readonly ILogger _logger; - private readonly FileAgentSkillLoader _loader; - private readonly AITool[] _tools; - private readonly string? _skillsInstructionPrompt; - - /// - /// Initializes a new instance of the class that searches a single directory for skills. - /// - /// Path to an individual skill folder (containing a SKILL.md file) or a parent folder with skill subdirectories. - /// Optional configuration for prompt customization. - /// Optional logger factory. - public FileAgentSkillsProvider(string skillPath, FileAgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null) - : this([skillPath], options, loggerFactory) - { - } - - /// - /// Initializes a new instance of the class that searches multiple directories for skills. - /// - /// Paths to search. Each can be an individual skill folder or a parent folder with skill subdirectories. - /// Optional configuration for prompt customization. - /// Optional logger factory. - public FileAgentSkillsProvider(IEnumerable skillPaths, FileAgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null) - { - _ = Throw.IfNull(skillPaths); - - this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); - - this._loader = new FileAgentSkillLoader(this._logger, options?.AllowedResourceExtensions); - this._skills = this._loader.DiscoverAndLoadSkills(skillPaths); - - this._skillsInstructionPrompt = BuildSkillsInstructionPrompt(options, this._skills); - - this._tools = - [ - AIFunctionFactory.Create( - this.LoadSkill, - name: "load_skill", - description: "Loads the full instructions for a specific skill."), - AIFunctionFactory.Create( - this.ReadSkillResourceAsync, - name: "read_skill_resource", - description: "Reads a file associated with a skill, such as references or assets."), - ]; - } - - /// - protected override ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) - { - if (this._skills.Count == 0) - { - return base.ProvideAIContextAsync(context, cancellationToken); - } - - return new ValueTask(new AIContext - { - Instructions = this._skillsInstructionPrompt, - Tools = this._tools - }); - } - - private string LoadSkill(string skillName) - { - if (string.IsNullOrWhiteSpace(skillName)) - { - return "Error: Skill name cannot be empty."; - } - - if (!this._skills.TryGetValue(skillName, out FileAgentSkill? skill)) - { - return $"Error: Skill '{skillName}' not found."; - } - - LogSkillLoading(this._logger, skillName); - - return skill.Body; - } - - private async Task ReadSkillResourceAsync(string skillName, string resourceName, CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(skillName)) - { - return "Error: Skill name cannot be empty."; - } - - if (string.IsNullOrWhiteSpace(resourceName)) - { - return "Error: Resource name cannot be empty."; - } - - if (!this._skills.TryGetValue(skillName, out FileAgentSkill? skill)) - { - return $"Error: Skill '{skillName}' not found."; - } - - try - { - return await this._loader.ReadSkillResourceAsync(skill, resourceName, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - LogResourceReadError(this._logger, skillName, resourceName, ex); - return $"Error: Failed to read resource '{resourceName}' from skill '{skillName}'."; - } - } - - private static string? BuildSkillsInstructionPrompt(FileAgentSkillsProviderOptions? options, Dictionary skills) - { - string promptTemplate = DefaultSkillsInstructionPrompt; - - if (options?.SkillsInstructionPrompt is { } optionsInstructions) - { - try - { - _ = string.Format(optionsInstructions, string.Empty); - } - catch (FormatException ex) - { - throw new ArgumentException( - "The provided SkillsInstructionPrompt is not a valid format string.", - nameof(options), - ex); - } - - if (optionsInstructions.IndexOf("{0}", StringComparison.Ordinal) < 0) - { - throw new ArgumentException( - "The provided SkillsInstructionPrompt must contain a '{0}' placeholder for the generated skills list.", - nameof(options)); - } - - promptTemplate = optionsInstructions; - } - - if (skills.Count == 0) - { - return null; - } - - var sb = new StringBuilder(); - - // Order by name for deterministic prompt output across process restarts - // (Dictionary enumeration order is not guaranteed and varies with hash randomization). - foreach (var skill in skills.Values.OrderBy(s => s.Frontmatter.Name, StringComparer.Ordinal)) - { - sb.AppendLine(" "); - sb.AppendLine($" {SecurityElement.Escape(skill.Frontmatter.Name)}"); - sb.AppendLine($" {SecurityElement.Escape(skill.Frontmatter.Description)}"); - sb.AppendLine(" "); - } - - return string.Format(promptTemplate, sb.ToString().TrimEnd()); - } - - [LoggerMessage(LogLevel.Information, "Loading skill: {SkillName}")] - private static partial void LogSkillLoading(ILogger logger, string skillName); - - [LoggerMessage(LogLevel.Error, "Failed to read resource '{ResourceName}' from skill '{SkillName}'")] - private static partial void LogResourceReadError(ILogger logger, string skillName, string resourceName, Exception exception); -} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs deleted file mode 100644 index 600c5b964c..0000000000 --- a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using Microsoft.Shared.DiagnosticIds; - -namespace Microsoft.Agents.AI; - -/// -/// Configuration options for . -/// -[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public sealed class FileAgentSkillsProviderOptions -{ - /// - /// Gets or sets a custom system prompt template for advertising skills. - /// Use {0} as the placeholder for the generated skills list. - /// When , a default template is used. - /// - public string? SkillsInstructionPrompt { get; set; } - - /// - /// Gets or sets the file extensions recognized as discoverable skill resources. - /// Each value must start with a '.' character (for example, .md), and - /// extension comparisons are performed in a case-insensitive manner. - /// Files in the skill directory (and its subdirectories) whose extension matches - /// one of these values will be automatically discovered as resources. - /// When , a default set of extensions is used - /// (.md, .json, .yaml, .yml, .csv, .xml, .txt). - /// - public IEnumerable? AllowedResourceExtensions { get; set; } -} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs b/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs deleted file mode 100644 index 123a6c43f4..0000000000 --- a/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI; - -/// -/// Parsed YAML frontmatter from a SKILL.md file, containing the skill's name and description. -/// -internal sealed class SkillFrontmatter -{ - /// - /// Initializes a new instance of the class. - /// - /// Skill name. - /// Skill description. - public SkillFrontmatter(string name, string description) - { - this.Name = Throw.IfNullOrWhitespace(name); - this.Description = Throw.IfNullOrWhitespace(description); - } - - /// - /// Gets the skill name. Lowercase letters, numbers, and hyphens only. - /// - public string Name { get; } - - /// - /// Gets the skill description. Used for discovery in the system prompt. - /// - public string Description { get; } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs new file mode 100644 index 0000000000..eb4f706f30 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class AgentFileSkillScriptTests +{ + [Fact] + public async Task RunAsync_SkillIsNotAgentFileSkill_ThrowsInvalidOperationExceptionAsync() + { + // Arrange + static Task RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, AIFunctionArguments a, CancellationToken ct) => Task.FromResult("result"); + var script = CreateScript("test-script", "/path/to/script.py", RunnerAsync); + var nonFileSkill = new TestAgentSkill("my-skill", "A skill", "Instructions."); + + // Act & Assert + await Assert.ThrowsAsync( + () => script.RunAsync(nonFileSkill, new AIFunctionArguments(), CancellationToken.None)); + } + + [Fact] + public async Task RunAsync_WithAgentFileSkill_DelegatesToRunnerAsync() + { + // Arrange + var runnerCalled = false; + Task runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, AIFunctionArguments args, CancellationToken ct) + { + runnerCalled = true; + return Task.FromResult("executed"); + } + var script = CreateScript("run-me", "/scripts/run-me.sh", runnerAsync); + var fileSkill = new AgentFileSkill( + new AgentSkillFrontmatter("my-skill", "A file skill"), + "---\nname: my-skill\n---\nContent", + "/skills/my-skill"); + + // Act + var result = await script.RunAsync(fileSkill, new AIFunctionArguments(), CancellationToken.None); + + // Assert + Assert.True(runnerCalled); + Assert.Equal("executed", result); + } + + [Fact] + public async Task RunAsync_RunnerReceivesCorrectArgumentsAsync() + { + // Arrange + AgentFileSkill? capturedSkill = null; + AgentFileSkillScript? capturedScript = null; + Task runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, AIFunctionArguments args, CancellationToken ct) + { + capturedSkill = skill; + capturedScript = scriptArg; + return Task.FromResult(null); + } + var script = CreateScript("capture", "/scripts/capture.py", runnerAsync); + var fileSkill = new AgentFileSkill( + new AgentSkillFrontmatter("owner-skill", "Owner"), + "Content", + "/skills/owner-skill"); + + // Act + await script.RunAsync(fileSkill, new AIFunctionArguments(), CancellationToken.None); + + // Assert + Assert.Same(fileSkill, capturedSkill); + Assert.Same(script, capturedScript); + } + + [Fact] + public void Script_HasCorrectNameAndPath() + { + // Arrange & Act + static Task RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, AIFunctionArguments a, CancellationToken ct) => Task.FromResult(null); + var script = CreateScript("my-script", "/path/to/my-script.py", RunnerAsync); + + // Assert + Assert.Equal("my-script", script.Name); + Assert.Equal("/path/to/my-script.py", script.FullPath); + } + + /// + /// Helper to create an via reflection since the constructor is internal. + /// + private static AgentFileSkillScript CreateScript(string name, string fullPath, AgentFileSkillScriptRunner executor) + { + var ctor = typeof(AgentFileSkillScript).GetConstructor( + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance, + null, + [typeof(string), typeof(string), typeof(AgentFileSkillScriptRunner)], + null) ?? throw new InvalidOperationException("Could not find internal constructor."); + + return (AgentFileSkillScript)ctor.Invoke([name, fullPath, executor]); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs new file mode 100644 index 0000000000..ef8f7780a6 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs @@ -0,0 +1,255 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for script discovery and execution in . +/// +public sealed class AgentFileSkillsSourceScriptTests : IDisposable +{ + private static readonly string[] s_rubyExtension = new[] { ".rb" }; + private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, ct) => Task.FromResult(null); + + private readonly string _testRoot; + + public AgentFileSkillsSourceScriptTests() + { + this._testRoot = Path.Combine(Path.GetTempPath(), "skills-source-script-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(this._testRoot); + } + + public void Dispose() + { + if (Directory.Exists(this._testRoot)) + { + Directory.Delete(this._testRoot, recursive: true); + } + } + + [Fact] + public async Task GetSkillsAsync_WithScriptFiles_DiscoversScriptsAsync() + { + // Arrange + CreateSkillWithScript(this._testRoot, "my-skill", "A test skill", "Body.", "scripts/convert.py", "print('hello')"); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(skills); + var skill = skills[0]; + Assert.NotNull(skill.Scripts); + Assert.Single(skill.Scripts!); + Assert.Equal("scripts/convert.py", skill.Scripts![0].Name); + } + + [Fact] + public async Task GetSkillsAsync_WithMultipleScriptExtensions_DiscoversAllAsync() + { + // Arrange + string skillDir = CreateSkillDir(this._testRoot, "multi-ext-skill", "Multi-extension skill", "Body."); + CreateFile(skillDir, "scripts/run.py", "print('py')"); + CreateFile(skillDir, "scripts/run.sh", "echo 'sh'"); + CreateFile(skillDir, "scripts/run.js", "console.log('js')"); + CreateFile(skillDir, "scripts/run.ps1", "Write-Host 'ps'"); + CreateFile(skillDir, "scripts/run.cs", "Console.WriteLine();"); + CreateFile(skillDir, "scripts/run.csx", "Console.WriteLine();"); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(skills); + var scriptNames = skills[0].Scripts!.Select(s => s.Name).OrderBy(n => n, StringComparer.Ordinal).ToList(); + Assert.Equal(6, scriptNames.Count); + Assert.Contains("scripts/run.cs", scriptNames); + Assert.Contains("scripts/run.csx", scriptNames); + Assert.Contains("scripts/run.js", scriptNames); + Assert.Contains("scripts/run.ps1", scriptNames); + Assert.Contains("scripts/run.py", scriptNames); + Assert.Contains("scripts/run.sh", scriptNames); + } + + [Fact] + public async Task GetSkillsAsync_NonScriptExtensionsAreNotDiscoveredAsync() + { + // Arrange + string skillDir = CreateSkillDir(this._testRoot, "no-script-skill", "Non-script skill", "Body."); + CreateFile(skillDir, "scripts/data.txt", "text data"); + CreateFile(skillDir, "scripts/config.json", "{}"); + CreateFile(skillDir, "scripts/notes.md", "# Notes"); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(skills); + Assert.Empty(skills[0].Scripts!); + } + + [Fact] + public async Task GetSkillsAsync_NoScriptFiles_ReturnsEmptyScriptsAsync() + { + // Arrange + CreateSkillDir(this._testRoot, "no-scripts", "No scripts skill", "Body."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(skills); + Assert.NotNull(skills[0].Scripts); + Assert.Empty(skills[0].Scripts!); + } + + [Fact] + public async Task GetSkillsAsync_ScriptsOutsideScriptsDir_AreAlsoDiscoveredAsync() + { + // Arrange — scripts at any depth in the skill directory are discovered + string skillDir = CreateSkillDir(this._testRoot, "root-scripts", "Root scripts skill", "Body."); + CreateFile(skillDir, "convert.py", "print('root')"); + CreateFile(skillDir, "tools/helper.sh", "echo 'helper'"); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(skills); + var scriptNames = skills[0].Scripts!.Select(s => s.Name).OrderBy(n => n, StringComparer.Ordinal).ToList(); + Assert.Equal(2, scriptNames.Count); + Assert.Contains("convert.py", scriptNames); + Assert.Contains("tools/helper.sh", scriptNames); + } + + [Fact] + public async Task GetSkillsAsync_WithRunner_ScriptsCanRunAsync() + { + // Arrange + CreateSkillWithScript(this._testRoot, "exec-skill", "Executor test", "Body.", "scripts/test.py", "print('ok')"); + var executorCalled = false; + var source = new AgentFileSkillsSource( + this._testRoot, + (skill, script, args, ct) => + { + executorCalled = true; + Assert.Equal("exec-skill", skill.Frontmatter.Name); + Assert.Equal("scripts/test.py", script.Name); + Assert.Equal(Path.GetFullPath(Path.Combine(this._testRoot, "exec-skill", "scripts", "test.py")), script.FullPath); + return Task.FromResult("executed"); + }); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + var scriptResult = await skills[0].Scripts![0].RunAsync(skills[0], new AIFunctionArguments(), CancellationToken.None); + + // Assert + Assert.True(executorCalled); + Assert.Equal("executed", scriptResult); + } + + [Fact] + public void Constructor_NullExecutor_DoesNotThrow() + { + // Arrange & Act & Assert — null runner is allowed when skills have no scripts + var source = new AgentFileSkillsSource(this._testRoot, null); + Assert.NotNull(source); + } + + [Fact] + public async Task GetSkillsAsync_ScriptsWithNoRunner_ThrowsOnRunAsync() + { + // Arrange + string skillDir = CreateSkillDir(this._testRoot, "no-runner-skill", "No runner", "Body."); + CreateFile(skillDir, "scripts/run.sh", "echo 'hello'"); + var source = new AgentFileSkillsSource(this._testRoot, scriptRunner: null); + + // Act — discovery succeeds even without a runner + var skills = await source.GetSkillsAsync(CancellationToken.None); + var script = skills[0].Scripts![0]; + + // Assert — running the script throws because no runner was provided + await Assert.ThrowsAsync(() => script.RunAsync(skills[0], new AIFunctionArguments(), CancellationToken.None)); + } + + [Fact] + public async Task GetSkillsAsync_CustomScriptExtensions_OnlyDiscoversMatchingAsync() + { + // Arrange + string skillDir = CreateSkillDir(this._testRoot, "custom-ext-skill", "Custom extensions", "Body."); + CreateFile(skillDir, "scripts/run.py", "print('py')"); + CreateFile(skillDir, "scripts/run.rb", "puts 'rb'"); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedScriptExtensions = s_rubyExtension }); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(skills); + Assert.Single(skills[0].Scripts!); + Assert.Equal("scripts/run.rb", skills[0].Scripts![0].Name); + } + + [Fact] + public async Task GetSkillsAsync_ExecutorReceivesArgumentsAsync() + { + // Arrange + CreateSkillWithScript(this._testRoot, "args-skill", "Args test", "Body.", "scripts/test.py", "print('ok')"); + AIFunctionArguments? capturedArgs = null; + var source = new AgentFileSkillsSource( + this._testRoot, + (skill, script, args, ct) => + { + capturedArgs = args; + return Task.FromResult("done"); + }); + + // Act + var skills = await source.GetSkillsAsync(CancellationToken.None); + var arguments = new AIFunctionArguments + { + ["value"] = 26.2, + ["factor"] = 1.60934 + }; + await skills[0].Scripts![0].RunAsync(skills[0], arguments, CancellationToken.None); + + // Assert + Assert.NotNull(capturedArgs); + Assert.Equal(26.2, capturedArgs["value"]); + Assert.Equal(1.60934, capturedArgs["factor"]); + } + + private static string CreateSkillDir(string root, string name, string description, string body) + { + string skillDir = Path.Combine(root, name); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + $"---\nname: {name}\ndescription: {description}\n---\n{body}"); + return skillDir; + } + + private static void CreateSkillWithScript(string root, string name, string description, string body, string scriptRelativePath, string scriptContent) + { + string skillDir = CreateSkillDir(root, name, description, body); + CreateFile(skillDir, scriptRelativePath, scriptContent); + } + + private static void CreateFile(string root, string relativePath, string content) + { + string fullPath = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + File.WriteAllText(fullPath, content); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillFrontmatterValidatorTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillFrontmatterValidatorTests.cs new file mode 100644 index 0000000000..c0f8412655 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillFrontmatterValidatorTests.cs @@ -0,0 +1,260 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for validation. +/// +public sealed class AgentSkillFrontmatterValidatorTests +{ + [Theory] + [InlineData("my-skill")] + [InlineData("a")] + [InlineData("skill123")] + [InlineData("a1b2c3")] + public void ValidateName_ValidName_ReturnsTrue(string name) + { + // Act + bool result = AgentSkillFrontmatter.ValidateName(name, out string? reason); + + // Assert + Assert.True(result); + Assert.Null(reason); + } + + [Theory] + [InlineData("-leading-hyphen")] + [InlineData("trailing-hyphen-")] + [InlineData("has spaces")] + [InlineData("UPPERCASE")] + [InlineData("consecutive--hyphens")] + [InlineData("special!chars")] + public void ValidateName_InvalidName_ReturnsFalse(string name) + { + // Act + bool result = AgentSkillFrontmatter.ValidateName(name, out string? reason); + + // Assert + Assert.False(result); + Assert.NotNull(reason); + Assert.Contains("name", reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ValidateName_NameExceedsMaxLength_ReturnsFalse() + { + // Arrange + string longName = new('a', 65); + + // Act + bool result = AgentSkillFrontmatter.ValidateName(longName, out string? reason); + + // Assert + Assert.False(result); + Assert.NotNull(reason); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ValidateName_NullOrWhitespace_ReturnsFalse(string? name) + { + // Act + bool result = AgentSkillFrontmatter.ValidateName(name, out string? reason); + + // Assert + Assert.False(result); + Assert.NotNull(reason); + } + + [Fact] + public void ValidateDescription_ValidDescription_ReturnsTrue() + { + // Act + bool result = AgentSkillFrontmatter.ValidateDescription("A valid description.", out string? reason); + + // Assert + Assert.True(result); + Assert.Null(reason); + } + + [Fact] + public void ValidateDescription_DescriptionExceedsMaxLength_ReturnsFalse() + { + // Arrange + string longDesc = new('x', 1025); + + // Act + bool result = AgentSkillFrontmatter.ValidateDescription(longDesc, out string? reason); + + // Assert + Assert.False(result); + Assert.NotNull(reason); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ValidateDescription_NullOrWhitespace_ReturnsFalse(string? description) + { + // Act + bool result = AgentSkillFrontmatter.ValidateDescription(description, out string? reason); + + // Assert + Assert.False(result); + Assert.NotNull(reason); + } + + [Fact] + public void ValidateCompatibility_Null_ReturnsTrue() + { + // Act + bool result = AgentSkillFrontmatter.ValidateCompatibility(null, out string? reason); + + // Assert + Assert.True(result); + Assert.Null(reason); + } + + [Fact] + public void ValidateCompatibility_WithinMaxLength_ReturnsTrue() + { + // Arrange + string compatibility = new('x', 500); + + // Act + bool result = AgentSkillFrontmatter.ValidateCompatibility(compatibility, out string? reason); + + // Assert + Assert.True(result); + Assert.Null(reason); + } + + [Fact] + public void ValidateCompatibility_ExceedsMaxLength_ReturnsFalse() + { + // Arrange + string compatibility = new('x', 501); + + // Act + bool result = AgentSkillFrontmatter.ValidateCompatibility(compatibility, out string? reason); + + // Assert + Assert.False(result); + Assert.NotNull(reason); + } + + [Theory] + [InlineData("UPPERCASE")] + [InlineData("-leading")] + [InlineData("trailing-")] + [InlineData("consecutive--hyphens")] + public void Constructor_InvalidName_ThrowsArgumentException(string name) + { + // Act & Assert + var ex = Assert.Throws(() => new AgentSkillFrontmatter(name, "A valid description.")); + Assert.Contains("name", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Constructor_NameExceedsMaxLength_ThrowsArgumentException() + { + // Arrange + string longName = new('a', 65); + + // Act & Assert + Assert.Throws(() => new AgentSkillFrontmatter(longName, "A valid description.")); + } + + [Fact] + public void Constructor_DescriptionExceedsMaxLength_ThrowsArgumentException() + { + // Arrange + string longDesc = new('x', 1025); + + // Act & Assert + Assert.Throws(() => new AgentSkillFrontmatter("valid-name", longDesc)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Constructor_NullOrWhitespaceName_ThrowsArgumentException(string? name) + { + // Act & Assert + Assert.Throws(() => new AgentSkillFrontmatter(name!, "A valid description.")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Constructor_NullOrWhitespaceDescription_ThrowsArgumentException(string? description) + { + // Act & Assert + Assert.Throws(() => new AgentSkillFrontmatter("valid-name", description!)); + } + + [Fact] + public void Compatibility_ExceedsMaxLength_ThrowsArgumentException() + { + // Arrange + var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description."); + string longCompatibility = new('x', 501); + + // Act & Assert + Assert.Throws(() => frontmatter.Compatibility = longCompatibility); + } + + [Fact] + public void Compatibility_WithinMaxLength_Succeeds() + { + // Arrange + var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description."); + string compatibility = new('x', 500); + + // Act + frontmatter.Compatibility = compatibility; + + // Assert + Assert.Equal(compatibility, frontmatter.Compatibility); + } + + [Fact] + public void Compatibility_Null_Succeeds() + { + // Arrange + var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description."); + + // Act + frontmatter.Compatibility = null; + + // Assert + Assert.Null(frontmatter.Compatibility); + } + + [Fact] + public void Constructor_WithCompatibility_SetsValue() + { + // Arrange & Act + var frontmatter = new AgentSkillFrontmatter("valid-name", "A valid description.", "Requires Python 3.10+"); + + // Assert + Assert.Equal("Requires Python 3.10+", frontmatter.Compatibility); + } + + [Fact] + public void Constructor_CompatibilityExceedsMaxLength_ThrowsArgumentException() + { + // Arrange + string longCompatibility = new('x', 501); + + // Act & Assert + Assert.Throws(() => new AgentSkillFrontmatter("valid-name", "A valid description.", longCompatibility)); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderBuilderTests.cs new file mode 100644 index 0000000000..85335256a7 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderBuilderTests.cs @@ -0,0 +1,229 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class AgentSkillsProviderBuilderTests +{ + private readonly TestAIAgent _agent = new(); + + private AIContextProvider.InvokingContext CreateInvokingContext() + { + return new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + } + + [Fact] + public void Build_NoSourceConfigured_Succeeds() + { + // Arrange + var builder = new AgentSkillsProviderBuilder(); + + // Act + var provider = builder.Build(); + + // Assert + Assert.NotNull(provider); + } + + [Fact] + public void Build_WithCustomSource_Succeeds() + { + // Arrange + var source = new TestAgentSkillsSource( + new TestAgentSkill("custom", "Custom skill", "Instructions.")); + var builder = new AgentSkillsProviderBuilder() + .UseSource(source); + + // Act + var provider = builder.Build(); + + // Assert + Assert.NotNull(provider); + } + + [Fact] + public void UseSource_NullSource_ThrowsArgumentNullException() + { + // Arrange + var builder = new AgentSkillsProviderBuilder(); + + // Act & Assert + Assert.Throws(() => builder.UseSource(null!)); + } + + [Fact] + public void UseFilter_NullPredicate_ThrowsArgumentNullException() + { + // Arrange + var builder = new AgentSkillsProviderBuilder(); + + // Act & Assert + Assert.Throws(() => builder.UseFilter(null!)); + } + + [Fact] + public void UseFileScriptRunner_NullRunner_ThrowsArgumentNullException() + { + // Arrange + var builder = new AgentSkillsProviderBuilder(); + + // Act & Assert + Assert.Throws(() => builder.UseFileScriptRunner(null!)); + } + + [Fact] + public void UseOptions_NullConfigure_ThrowsArgumentNullException() + { + // Arrange + var builder = new AgentSkillsProviderBuilder(); + + // Act & Assert + Assert.Throws(() => builder.UseOptions(null!)); + } + + [Fact] + public async Task Build_WithFilter_AppliesFilterToSkillsAsync() + { + // Arrange + var source = new TestAgentSkillsSource( + new TestAgentSkill("keep-me", "Keep", "Instructions."), + new TestAgentSkill("drop-me", "Drop", "Instructions.")); + var provider = new AgentSkillsProviderBuilder() + .UseSource(source) + .UseFilter(skill => skill.Frontmatter.Name.StartsWith("keep", StringComparison.OrdinalIgnoreCase)) + .Build(); + + // Act + var result = await provider.InvokingAsync( + this.CreateInvokingContext(), CancellationToken.None); + + // Assert — the instructions should mention "keep-me" but not "drop-me" + Assert.NotNull(result.Instructions); + Assert.Contains("keep-me", result.Instructions); + Assert.DoesNotContain("drop-me", result.Instructions); + } + + [Fact] + public async Task Build_WithCacheDisabled_ReloadsOnEachCallAsync() + { + // Arrange + var countingSource = new CountingSource( + new TestAgentSkill("skill-a", "A", "Instructions.")); + var provider = new AgentSkillsProviderBuilder() + .UseSource(countingSource) + .UseOptions(o => o.DisableCaching = true) + .Build(); + + // Act + await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None); + await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None); + + // Assert — inner source should be called each time (dedup still calls through) + Assert.True(countingSource.CallCount >= 2); + } + + [Fact] + public async Task Build_WithCacheEnabled_CachesSkillsAsync() + { + // Arrange + var countingSource = new CountingSource( + new TestAgentSkill("skill-a", "A", "Instructions.")); + var provider = new AgentSkillsProviderBuilder() + .UseSource(countingSource) + .Build(); + + // Act + await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None); + await provider.InvokingAsync(this.CreateInvokingContext(), CancellationToken.None); + + // Assert — inner source should only be called once due to caching + Assert.Equal(1, countingSource.CallCount); + } + + [Fact] + public void Build_FluentChaining_ReturnsSameBuilder() + { + // Arrange + var builder = new AgentSkillsProviderBuilder(); + var source = new TestAgentSkillsSource( + new TestAgentSkill("test", "Test", "Instructions.")); + + // Act — all fluent methods should return the same builder + var result = builder + .UseSource(source) + .UseScriptApproval(false) + .UsePromptTemplate("Skills:\n{skills}\n{resource_instructions}\n{script_instructions}"); + + // Assert + Assert.Same(builder, result); + } + + [Fact] + public void Build_UseOptions_ConfiguresOptions() + { + // Arrange + var source = new TestAgentSkillsSource( + new TestAgentSkill("test", "Test", "Instructions.")); + + // Act — UseOptions should not throw and successfully configure + var provider = new AgentSkillsProviderBuilder() + .UseSource(source) + .UseOptions(opts => opts.ScriptApproval = true) + .Build(); + + // Assert + Assert.NotNull(provider); + } + + [Fact] + public async Task Build_WithMultipleCustomSources_AggregatesAllAsync() + { + // Arrange + var source1 = new TestAgentSkillsSource( + new TestAgentSkill("from-one", "Source 1", "Instructions 1.")); + var source2 = new TestAgentSkillsSource( + new TestAgentSkill("from-two", "Source 2", "Instructions 2.")); + var provider = new AgentSkillsProviderBuilder() + .UseSource(source1) + .UseSource(source2) + .Build(); + + // Act + var result = await provider.InvokingAsync( + this.CreateInvokingContext(), CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("from-one", result.Instructions); + Assert.Contains("from-two", result.Instructions); + } + + /// + /// A test source that counts how many times GetSkillsAsync is called. + /// + private sealed class CountingSource : AgentSkillsSource + { + private readonly AgentSkill[] _skills; + private int _callCount; + + public CountingSource(params AgentSkill[] skills) + { + this._skills = skills; + } + + public int CallCount => this._callCount; + + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref this._callCount); + return Task.FromResult>(this._skills); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs new file mode 100644 index 0000000000..6dfc45918a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs @@ -0,0 +1,765 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for the class with . +/// +public sealed class AgentSkillsProviderTests : IDisposable +{ + private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, ct) => Task.FromResult(null); + private readonly string _testRoot; + private readonly TestAIAgent _agent = new(); + + public AgentSkillsProviderTests() + { + this._testRoot = Path.Combine(Path.GetTempPath(), "skills-provider-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(this._testRoot); + } + + public void Dispose() + { + if (Directory.Exists(this._testRoot)) + { + Directory.Delete(this._testRoot, recursive: true); + } + } + + [Fact] + public async Task InvokingCoreAsync_NoSkills_ReturnsInputContextUnchangedAsync() + { + // Arrange + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + var inputContext = new AIContext { Instructions = "Original instructions" }; + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.Equal("Original instructions", result.Instructions); + Assert.Null(result.Tools); + } + + [Fact] + public async Task InvokingCoreAsync_WithSkills_AppendsInstructionsAndToolsAsync() + { + // Arrange + this.CreateSkill("provider-skill", "Provider skill test", "Skill instructions body."); + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + var inputContext = new AIContext { Instructions = "Base instructions" }; + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("Base instructions", result.Instructions); + Assert.Contains("provider-skill", result.Instructions); + Assert.Contains("Provider skill test", result.Instructions); + + // Should have load_skill tool (no resources, so no read_skill_resource) + Assert.NotNull(result.Tools); + var toolNames = result.Tools!.Select(t => t.Name).ToList(); + Assert.Contains("load_skill", toolNames); + Assert.DoesNotContain("read_skill_resource", toolNames); + } + + [Fact] + public async Task InvokingCoreAsync_NullInputInstructions_SetsInstructionsAsync() + { + // Arrange + this.CreateSkill("null-instr-skill", "Null instruction test", "Body."); + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("null-instr-skill", result.Instructions); + } + + [Fact] + public async Task InvokingCoreAsync_CustomPromptTemplate_UsesCustomTemplateAsync() + { + // Arrange + this.CreateSkill("custom-prompt-skill", "Custom prompt", "Body."); + var options = new AgentSkillsProviderOptions + { + SkillsInstructionPrompt = "Custom template: {skills}\n{resource_instructions}\n{script_instructions}" + }; + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.StartsWith("Custom template:", result.Instructions); + Assert.Contains("custom-prompt-skill", result.Instructions); + Assert.Contains("Custom prompt", result.Instructions); + } + + [Fact] + public void Constructor_PromptWithoutSkillsPlaceholder_ThrowsArgumentException() + { + // Arrange + var options = new AgentSkillsProviderOptions + { + SkillsInstructionPrompt = "No skills placeholder here {resource_instructions} {script_instructions}" + }; + + // Act & Assert + var ex = Assert.Throws(() => + new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options)); + Assert.Contains("{skills}", ex.Message); + Assert.Equal("options", ex.ParamName); + } + + [Fact] + public void Constructor_PromptWithoutRunnerInstructionsPlaceholder_ThrowsArgumentException() + { + // Arrange + var options = new AgentSkillsProviderOptions + { + SkillsInstructionPrompt = "Has skills {skills} but no runner instructions {resource_instructions}" + }; + + // Act & Assert + var ex = Assert.Throws(() => + new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options)); + Assert.Contains("{script_instructions}", ex.Message); + Assert.Equal("options", ex.ParamName); + } + + [Fact] + public void Constructor_PromptWithBothPlaceholders_Succeeds() + { + // Arrange + var options = new AgentSkillsProviderOptions + { + SkillsInstructionPrompt = "Skills: {skills}\nResources: {resource_instructions}\nRunner: {script_instructions}" + }; + + // Act — should not throw + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options); + + // Assert + Assert.NotNull(provider); + } + + [Fact] + public void Constructor_PromptWithoutResourceInstructionsPlaceholder_ThrowsArgumentException() + { + // Arrange + var options = new AgentSkillsProviderOptions + { + SkillsInstructionPrompt = "Has skills {skills} and runner {script_instructions} but no resource instructions" + }; + + // Act & Assert + var ex = Assert.Throws(() => + new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor), options)); + Assert.Contains("{resource_instructions}", ex.Message); + Assert.Equal("options", ex.ParamName); + } + + [Fact] + public async Task InvokingCoreAsync_SkillNamesAreXmlEscapedAsync() + { + // Arrange — description with XML-sensitive characters + string skillDir = Path.Combine(this._testRoot, "xml-skill"); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: xml-skill\ndescription: Uses & \"quotes\"\n---\nBody."); + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("<tags>", result.Instructions); + Assert.Contains("&", result.Instructions); + } + + [Fact] + public async Task Constructor_WithMultiplePaths_LoadsFromAllAsync() + { + // Arrange + string dir1 = Path.Combine(this._testRoot, "dir1"); + string dir2 = Path.Combine(this._testRoot, "dir2"); + CreateSkillIn(dir1, "skill-a", "Skill A", "Body A."); + CreateSkillIn(dir2, "skill-b", "Skill B", "Body B."); + + // Act + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(new[] { dir1, dir2 }, s_noOpExecutor)); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Assert + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + Assert.NotNull(result.Instructions); + Assert.Contains("skill-a", result.Instructions); + Assert.Contains("skill-b", result.Instructions); + } + + [Fact] + public async Task InvokingCoreAsync_PreservesExistingInputToolsAsync() + { + // Arrange + this.CreateSkill("tools-skill", "Tools test", "Body."); + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + + var existingTool = AIFunctionFactory.Create(() => "test", name: "existing_tool", description: "An existing tool."); + var inputContext = new AIContext { Tools = new[] { existingTool } }; + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — existing tool should be preserved alongside the new skill tools + Assert.NotNull(result.Tools); + var toolNames = result.Tools!.Select(t => t.Name).ToList(); + Assert.Contains("existing_tool", toolNames); + Assert.Contains("load_skill", toolNames); + } + + [Fact] + public async Task InvokingCoreAsync_SkillsListIsSortedByNameAsync() + { + // Arrange — create skills in reverse alphabetical order + this.CreateSkill("zulu-skill", "Zulu skill", "Body Z."); + this.CreateSkill("alpha-skill", "Alpha skill", "Body A."); + this.CreateSkill("mike-skill", "Mike skill", "Body M."); + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — skills should appear in alphabetical order in the prompt + Assert.NotNull(result.Instructions); + int alphaIndex = result.Instructions!.IndexOf("alpha-skill", StringComparison.Ordinal); + int mikeIndex = result.Instructions.IndexOf("mike-skill", StringComparison.Ordinal); + int zuluIndex = result.Instructions.IndexOf("zulu-skill", StringComparison.Ordinal); + Assert.True(alphaIndex < mikeIndex, "alpha-skill should appear before mike-skill"); + Assert.True(mikeIndex < zuluIndex, "mike-skill should appear before zulu-skill"); + } + + [Fact] + public async Task ProvideAIContextAsync_ConcurrentCalls_LoadsSkillsOnlyOnceAsync() + { + // Arrange + var source = new CountingAgentSkillsSource( + [ + new TestAgentSkill("concurrent-skill", "Concurrent test", "Body.") + ]); + var provider = new AgentSkillsProvider(source); + + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act — invoke concurrently from multiple threads + var tasks = Enumerable.Range(0, 10) + .Select(_ => provider.InvokingAsync(invokingContext, CancellationToken.None).AsTask()) + .ToArray(); + await Task.WhenAll(tasks); + + // Assert — GetSkillsAsync should have been called exactly once (provider-level caching) + Assert.Equal(1, source.GetSkillsCallCount); + } + + [Fact] + public async Task InvokingCoreAsync_WithScripts_IncludesRunSkillScriptToolAsync() + { + // Arrange + string skillDir = Path.Combine(this._testRoot, "script-skill"); + Directory.CreateDirectory(Path.Combine(skillDir, "scripts")); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: script-skill\ndescription: Skill with scripts\n---\nBody."); + File.WriteAllText( + Path.Combine(skillDir, "scripts", "test.py"), + "print('hello')"); + + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var provider = new AgentSkillsProvider(source); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Tools); + var toolNames = result.Tools!.Select(t => t.Name).ToList(); + Assert.Contains("run_skill_script", toolNames); + Assert.Contains("load_skill", toolNames); + } + + [Fact] + public async Task InvokingCoreAsync_WithoutScripts_NoRunSkillScriptToolAsync() + { + // Arrange + this.CreateSkill("no-script-skill", "No scripts", "Body."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var provider = new AgentSkillsProvider(source); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Tools); + var toolNames = result.Tools!.Select(t => t.Name).ToList(); + Assert.DoesNotContain("run_skill_script", toolNames); + } + + [Fact] + public void Build_WithFileSkillsButNoExecutor_ThrowsInvalidOperationException() + { + // Arrange + var builder = new AgentSkillsProviderBuilder() + .UseFileSkill(this._testRoot); + + // Act & Assert + Assert.Throws(() => builder.Build()); + } + + [Fact] + public async Task Builder_UseFileSkillWithOptions_DiscoverSkillsAsync() + { + // Arrange + this.CreateSkill("opts-skill", "Options skill", "Options body."); + var options = new AgentFileSkillsSourceOptions(); + var provider = new AgentSkillsProviderBuilder() + .UseFileSkill(this._testRoot, options) + .UseFileScriptRunner(s_noOpExecutor) + .UseOptions(o => o.DisableCaching = true) + .Build(); + + // Act + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("opts-skill", result.Instructions); + } + + [Fact] + public async Task Builder_UseFileSkillsWithOptions_DiscoverMultipleSkillsAsync() + { + // Arrange + string dir1 = Path.Combine(this._testRoot, "multi-opts-1"); + string dir2 = Path.Combine(this._testRoot, "multi-opts-2"); + CreateSkillIn(dir1, "skill-x", "Skill X", "Body X."); + CreateSkillIn(dir2, "skill-y", "Skill Y", "Body Y."); + + var options = new AgentFileSkillsSourceOptions(); + var provider = new AgentSkillsProviderBuilder() + .UseFileSkills(new[] { dir1, dir2 }, options) + .UseFileScriptRunner(s_noOpExecutor) + .UseOptions(o => o.DisableCaching = true) + .Build(); + + // Act + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("skill-x", result.Instructions); + Assert.Contains("skill-y", result.Instructions); + } + + [Fact] + public async Task Builder_UseFileSkillWithOptionsResourceFilter_FiltersResourcesAsync() + { + // Arrange — create a skill with both .md and .json resources + string skillDir = Path.Combine(this._testRoot, "res-filter-opts"); + CreateSkillIn(skillDir, "filter-skill", "Filter test", "Filter body."); + File.WriteAllText(Path.Combine(skillDir, "data.json"), "{}", System.Text.Encoding.UTF8); + File.WriteAllText(Path.Combine(skillDir, "notes.txt"), "notes", System.Text.Encoding.UTF8); + + // Only allow .json resources + var options = new AgentFileSkillsSourceOptions + { + AllowedResourceExtensions = [".json"], + }; + var source = new AgentFileSkillsSource(skillDir, s_noOpExecutor, options); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + var fileSkill = Assert.IsType(skills[0]); + Assert.All(fileSkill.Resources, r => Assert.EndsWith(".json", r.Name)); + } + + private void CreateSkill(string name, string description, string body) + { + CreateSkillIn(this._testRoot, name, description, body); + } + + [Fact] + public async Task LoadSkill_DefaultOptions_ReturnsFullContentAsync() + { + // Arrange + this.CreateSkill("content-skill", "Content test", "Skill body."); + var provider = new AgentSkillsProvider(new AgentFileSkillsSource(this._testRoot, s_noOpExecutor)); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction; + + // Act + var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary { ["skillName"] = "content-skill" })); + + // Assert — should contain frontmatter and body + var text = content!.ToString()!; + Assert.Contains("---", text); + Assert.Contains("name: content-skill", text); + Assert.Contains("Skill body.", text); + } + + [Fact] + public async Task Builder_UseFileScriptRunnerAfterUseFileSkills_RunnerIsUsedAsync() + { + // Arrange — create a skill with a script file + string skillDir = Path.Combine(this._testRoot, "builder-skill"); + Directory.CreateDirectory(Path.Combine(skillDir, "scripts")); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: builder-skill\ndescription: Builder test\n---\nBody."); + File.WriteAllText( + Path.Combine(skillDir, "scripts", "run.py"), + "print('ok')"); + + var executorCalled = false; + + // Act — call UseFileScriptRunner AFTER UseFileSkill (the bug scenario) + var provider = new AgentSkillsProviderBuilder() + .UseFileSkill(this._testRoot) + .UseFileScriptRunner((skill, script, args, ct) => + { + executorCalled = true; + return Task.FromResult("executed"); + }) + .Build(); + + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — run_skill_script tool should be present and executor should work + Assert.NotNull(result.Tools); + var toolNames = result.Tools!.Select(t => t.Name).ToList(); + Assert.Contains("run_skill_script", toolNames); + + var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction; + await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary + { + ["skillName"] = "builder-skill", + ["scriptName"] = "scripts/run.py", + })); + + Assert.True(executorCalled); + } + + private static void CreateSkillIn(string root, string name, string description, string body) + { + string skillDir = Path.Combine(root, name); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + $"---\nname: {name}\ndescription: {description}\n---\n{body}"); + } + + [Fact] + public async Task Build_WithCachingDisabled_ReloadsSkillsOnEachCallAsync() + { + // Arrange + var source = new CountingAgentSkillsSource( + [ + new TestAgentSkill("no-cache-skill", "No cache test", "Body.") + ]); + var provider = new AgentSkillsProviderBuilder() + .UseSource(source) + .UseOptions(o => o.DisableCaching = true) + .Build(); + + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + await provider.InvokingAsync(invokingContext, CancellationToken.None); + await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — source should be called more than once since caching is disabled + Assert.True(source.GetSkillsCallCount > 1); + } + + [Fact] + public async Task Build_WithCachingEnabled_CachesSkillsAsync() + { + // Arrange + var source = new CountingAgentSkillsSource( + [ + new TestAgentSkill("cached-skill", "Cached test", "Body.") + ]); + var provider = new AgentSkillsProviderBuilder() + .UseSource(source) + .Build(); + + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + await provider.InvokingAsync(invokingContext, CancellationToken.None); + await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — source should be called exactly once (caching is on by default) + Assert.Equal(1, source.GetSkillsCallCount); + } + + [Fact] + public async Task Build_DefaultOptions_CachesSkillsAsync() + { + // Arrange + var source = new CountingAgentSkillsSource( + [ + new TestAgentSkill("default-skill", "Default test", "Body.") + ]); + var provider = new AgentSkillsProviderBuilder() + .UseSource(source) + .Build(); + + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + await provider.InvokingAsync(invokingContext, CancellationToken.None); + await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — default behavior caches + Assert.Equal(1, source.GetSkillsCallCount); + } + + [Fact] + public async Task InvokingCoreAsync_WithScriptsAndScriptApproval_WrapsRunScriptToolAsync() + { + // Arrange — create a skill with a script and enable ScriptApproval + string skillDir = Path.Combine(this._testRoot, "approval-skill"); + Directory.CreateDirectory(Path.Combine(skillDir, "scripts")); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: approval-skill\ndescription: Approval test\n---\nBody."); + File.WriteAllText( + Path.Combine(skillDir, "scripts", "run.py"), + "print('hello')"); + + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var options = new AgentSkillsProviderOptions { ScriptApproval = true }; + var provider = new AgentSkillsProvider(source, options); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — run_skill_script tool should be wrapped in ApprovalRequiredAIFunction + Assert.NotNull(result.Tools); + var scriptTool = result.Tools!.FirstOrDefault(t => t.Name == "run_skill_script"); + Assert.NotNull(scriptTool); + Assert.IsType(scriptTool); + } + + [Fact] + public async Task InvokingCoreAsync_WithScriptsNoScriptApproval_DoesNotWrapRunScriptToolAsync() + { + // Arrange — create a skill with a script, default options (no approval) + string skillDir = Path.Combine(this._testRoot, "no-approval-skill"); + Directory.CreateDirectory(Path.Combine(skillDir, "scripts")); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: no-approval-skill\ndescription: No approval test\n---\nBody."); + File.WriteAllText( + Path.Combine(skillDir, "scripts", "run.py"), + "print('hello')"); + + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var provider = new AgentSkillsProvider(source); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — run_skill_script tool should NOT be wrapped + Assert.NotNull(result.Tools); + var scriptTool = result.Tools!.FirstOrDefault(t => t.Name == "run_skill_script"); + Assert.NotNull(scriptTool); + Assert.IsNotType(scriptTool); + } + + [Fact] + public async Task InvokingCoreAsync_MultipleInvocations_ToolsAreSharedWhenCachedAsync() + { + // Arrange — with default caching, tools should be the same reference + this.CreateSkill("cached-tools-skill", "Cached tools test", "Body."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var provider = new AgentSkillsProvider(source); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result1 = await provider.InvokingAsync(invokingContext, CancellationToken.None); + var result2 = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — tool lists should be the same reference (cached) + Assert.NotNull(result1.Tools); + Assert.NotNull(result2.Tools); + Assert.Same(result1.Tools, result2.Tools); + } + + [Fact] + public async Task InvokingCoreAsync_MultipleInvocations_ToolsAreNotSharedWhenCachingDisabledAsync() + { + // Arrange — with caching disabled, tools should be rebuilt per invocation + this.CreateSkill("fresh-tools-skill", "Fresh tools test", "Body."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var options = new AgentSkillsProviderOptions { DisableCaching = true }; + var provider = new AgentSkillsProvider(source, options); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result1 = await provider.InvokingAsync(invokingContext, CancellationToken.None); + var result2 = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — tool lists should not be the same reference + Assert.NotNull(result1.Tools); + Assert.NotNull(result2.Tools); + Assert.NotSame(result1.Tools, result2.Tools); + } + + [Fact] + public async Task Constructor_SingleDirectory_DiscoverFileSkillsAsync() + { + // Arrange + this.CreateSkill("file-ctor-skill", "File ctor test", "File body."); + var provider = new AgentSkillsProvider(this._testRoot, s_noOpExecutor); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("file-ctor-skill", result.Instructions); + Assert.NotNull(result.Tools); + Assert.Contains(result.Tools!, t => t.Name == "load_skill"); + } + + [Fact] + public async Task Constructor_MultipleDirectories_DiscoverFileSkillsAsync() + { + // Arrange + string dir1 = Path.Combine(this._testRoot, "dir1"); + string dir2 = Path.Combine(this._testRoot, "dir2"); + Directory.CreateDirectory(dir1); + Directory.CreateDirectory(dir2); + CreateSkillIn(dir1, "skill-a", "Skill A", "Body A."); + CreateSkillIn(dir2, "skill-b", "Skill B", "Body B."); + + var provider = new AgentSkillsProvider(new[] { dir1, dir2 }, s_noOpExecutor); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("skill-a", result.Instructions); + Assert.Contains("skill-b", result.Instructions); + } + + [Fact] + public async Task Constructor_MultipleDirectories_DeduplicatesSkillsByNameAsync() + { + // Arrange — same skill name in two directories + string dir1 = Path.Combine(this._testRoot, "dup1"); + string dir2 = Path.Combine(this._testRoot, "dup2"); + Directory.CreateDirectory(dir1); + Directory.CreateDirectory(dir2); + CreateSkillIn(dir1, "dup-skill", "First", "Body 1."); + CreateSkillIn(dir2, "dup-skill", "Second", "Body 2."); + + var provider = new AgentSkillsProvider(new[] { dir1, dir2 }, s_noOpExecutor); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction; + var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary { ["skillName"] = "dup-skill" })); + + // Assert — only first occurrence should survive + Assert.NotNull(content); + Assert.Contains("Body 1.", content!.ToString()!); + } + + /// + /// A test skill source that counts how many times is called. + /// + private sealed class CountingAgentSkillsSource : AgentSkillsSource + { + private readonly IList _skills; + private int _callCount; + + public CountingAgentSkillsSource(IList skills) + { + this._skills = skills; + } + + public int GetSkillsCallCount => this._callCount; + + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref this._callCount); + return Task.FromResult(this._skills); + } + } + + private sealed class TestAgentSkill : AgentSkill + { + private readonly string _content; + + public TestAgentSkill(string name, string description, string content) + { + this.Frontmatter = new AgentSkillFrontmatter(name, description); + this._content = content; + } + + public override AgentSkillFrontmatter Frontmatter { get; } + + public override string Content => this._content; + + public override IReadOnlyList? Resources => null; + + public override IReadOnlyList? Scripts => null; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs new file mode 100644 index 0000000000..860f402005 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/DeduplicatingAgentSkillsSourceTests.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class DeduplicatingAgentSkillsSourceTests +{ + [Fact] + public async Task GetSkillsAsync_NoDuplicates_ReturnsAllSkillsAsync() + { + // Arrange + var inner = new TestAgentSkillsSource( + new TestAgentSkill("skill-a", "A", "Instructions A."), + new TestAgentSkill("skill-b", "B", "Instructions B.")); + var source = new DeduplicatingAgentSkillsSource(inner); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Equal(2, result.Count); + } + + [Fact] + public async Task GetSkillsAsync_WithDuplicates_KeepsFirstOccurrenceAsync() + { + // Arrange + var skills = new AgentSkill[] + { + new TestAgentSkill("dupe", "First", "Instructions 1."), + new TestAgentSkill("dupe", "Second", "Instructions 2."), + new TestAgentSkill("unique", "Unique", "Instructions 3."), + }; + var inner = new TestAgentSkillsSource(skills); + var source = new DeduplicatingAgentSkillsSource(inner); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("First", result.First(s => s.Frontmatter.Name == "dupe").Frontmatter.Description); + Assert.Contains(result, s => s.Frontmatter.Name == "unique"); + } + + [Fact] + public async Task GetSkillsAsync_CaseInsensitiveDuplication_KeepsFirstAsync() + { + // Arrange — use a custom source that returns skills with same name but different casing + var inner = new FakeDuplicateCaseSource(); + var source = new DeduplicatingAgentSkillsSource(inner); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Single(result); + Assert.Equal("First", result[0].Frontmatter.Description); + } + + [Fact] + public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync() + { + // Arrange + var inner = new TestAgentSkillsSource(System.Array.Empty()); + var source = new DeduplicatingAgentSkillsSource(inner); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Empty(result); + } + + /// + /// A fake source that returns skills with names differing only by case. + /// + private sealed class FakeDuplicateCaseSource : AgentSkillsSource + { + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + // AgentSkillFrontmatter validates names must be lowercase, so we build + // two skills with the same lowercase name to test case-insensitive dedup. + var skills = new List + { + new TestAgentSkill("my-skill", "First", "Instructions 1."), + new TestAgentSkill("my-skill", "Second", "Instructions 2."), + }; + return Task.FromResult>(skills); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs index 6134b04feb..e9dc2e0358 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs @@ -4,25 +4,25 @@ using System; using System.IO; using System.Linq; using System.Threading.Tasks; -using Microsoft.Extensions.Logging.Abstractions; namespace Microsoft.Agents.AI.UnitTests.AgentSkills; /// -/// Unit tests for the class. +/// Unit tests for the skill discovery and parsing logic. /// public sealed class FileAgentSkillLoaderTests : IDisposable { - private static readonly string[] s_traversalResource = new[] { "../secret.txt" }; + private static readonly string[] s_customExtensions = [".custom"]; + private static readonly string[] s_validExtensions = [".md", ".json", ".custom"]; + private static readonly string[] s_mixedValidInvalidExtensions = [".md", "json"]; + private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, ct) => Task.FromResult(null); private readonly string _testRoot; - private readonly FileAgentSkillLoader _loader; public FileAgentSkillLoaderTests() { this._testRoot = Path.Combine(Path.GetTempPath(), "agent-skills-tests-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(this._testRoot); - this._loader = new FileAgentSkillLoader(NullLogger.Instance); } public void Dispose() @@ -34,23 +34,23 @@ public sealed class FileAgentSkillLoaderTests : IDisposable } [Fact] - public void DiscoverAndLoadSkills_ValidSkill_ReturnsSkill() + public async Task GetSkillsAsync_ValidSkill_ReturnsSkillAsync() { // Arrange _ = this.CreateSkillDirectory("my-skill", "A test skill", "Use this skill to do things."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - Assert.True(skills.ContainsKey("my-skill")); - Assert.Equal("A test skill", skills["my-skill"].Frontmatter.Description); - Assert.Equal("Use this skill to do things.", skills["my-skill"].Body); + Assert.Equal("my-skill", skills[0].Frontmatter.Name); + Assert.Equal("A test skill", skills[0].Frontmatter.Description); } [Fact] - public void DiscoverAndLoadSkills_QuotedFrontmatterValues_ParsesCorrectly() + public async Task GetSkillsAsync_QuotedFrontmatterValues_ParsesCorrectlyAsync() { // Arrange string skillDir = Path.Combine(this._testRoot, "quoted-skill"); @@ -58,33 +58,35 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: 'quoted-skill'\ndescription: \"A quoted description\"\n---\nBody text."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - Assert.Equal("quoted-skill", skills["quoted-skill"].Frontmatter.Name); - Assert.Equal("A quoted description", skills["quoted-skill"].Frontmatter.Description); + Assert.Equal("quoted-skill", skills[0].Frontmatter.Name); + Assert.Equal("A quoted description", skills[0].Frontmatter.Description); } [Fact] - public void DiscoverAndLoadSkills_MissingFrontmatter_ExcludesSkill() + public async Task GetSkillsAsync_MissingFrontmatter_ExcludesSkillAsync() { // Arrange string skillDir = Path.Combine(this._testRoot, "bad-skill"); Directory.CreateDirectory(skillDir); File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "No frontmatter here."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_MissingNameField_ExcludesSkill() + public async Task GetSkillsAsync_MissingNameField_ExcludesSkillAsync() { // Arrange string skillDir = Path.Combine(this._testRoot, "no-name"); @@ -92,16 +94,17 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\ndescription: A skill without a name\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_MissingDescriptionField_ExcludesSkill() + public async Task GetSkillsAsync_MissingDescriptionField_ExcludesSkillAsync() { // Arrange string skillDir = Path.Combine(this._testRoot, "no-desc"); @@ -109,9 +112,10 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: no-desc\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); @@ -123,7 +127,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable [InlineData("trailing-hyphen-")] [InlineData("has spaces")] [InlineData("consecutive--hyphens")] - public void DiscoverAndLoadSkills_InvalidName_ExcludesSkill(string invalidName) + public async Task GetSkillsAsync_InvalidName_ExcludesSkillAsync(string invalidName) { // Arrange string skillDir = Path.Combine(this._testRoot, invalidName); @@ -136,16 +140,17 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), $"---\nname: {invalidName}\ndescription: A skill\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_DuplicateNames_KeepsFirstOnly() + public async Task GetSkillsAsync_DuplicateNames_KeepsFirstOnlyAsync() { // Arrange string dir1 = Path.Combine(this._testRoot, "dupe"); @@ -162,34 +167,37 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(nestedDir, "SKILL.md"), "---\nname: dupe\ndescription: Second\n---\nSecond body."); + var fileSource = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var source = new DeduplicatingAgentSkillsSource(fileSource); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert – filesystem enumeration order is not guaranteed, so we only // verify that exactly one of the two duplicates was kept. Assert.Single(skills); - string desc = skills["dupe"].Frontmatter.Description; + string desc = skills[0].Frontmatter.Description; Assert.True(desc == "First" || desc == "Second", $"Unexpected description: {desc}"); } [Fact] - public void DiscoverAndLoadSkills_NameMismatchesDirectory_ExcludesSkill() + public async Task GetSkillsAsync_NameMismatchesDirectory_ExcludesSkillAsync() { // Arrange — directory name differs from the frontmatter name _ = this.CreateSkillDirectoryWithRawContent( "wrong-dir-name", "---\nname: actual-skill-name\ndescription: A skill\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_FilesWithMatchingExtensions_DiscoveredAsResources() + public async Task GetSkillsAsync_FilesWithMatchingExtensions_DiscoveredAsResourcesAsync() { // Arrange — create resource files in the skill directory string skillDir = Path.Combine(this._testRoot, "resource-skill"); @@ -200,20 +208,21 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: resource-skill\ndescription: Has resources\n---\nSee docs for details."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - var skill = skills["resource-skill"]; - Assert.Equal(2, skill.ResourceNames.Count); - Assert.Contains(skill.ResourceNames, r => r.Equals("refs/FAQ.md", StringComparison.OrdinalIgnoreCase)); - Assert.Contains(skill.ResourceNames, r => r.Equals("refs/data.json", StringComparison.OrdinalIgnoreCase)); + var skill = skills[0]; + Assert.Equal(2, skill.Resources!.Count); + Assert.Contains(skill.Resources!, r => r.Name.Equals("refs/FAQ.md", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(skill.Resources!, r => r.Name.Equals("refs/data.json", StringComparison.OrdinalIgnoreCase)); } [Fact] - public void DiscoverAndLoadSkills_FilesWithNonMatchingExtensions_NotDiscovered() + public async Task GetSkillsAsync_FilesWithNonMatchingExtensions_NotDiscoveredAsync() { // Arrange — create a file with an extension not in the default list string skillDir = Path.Combine(this._testRoot, "ext-skill"); @@ -223,19 +232,20 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: ext-skill\ndescription: Extension test\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - var skill = skills["ext-skill"]; - Assert.Single(skill.ResourceNames); - Assert.Equal("data.json", skill.ResourceNames[0]); + var skill = skills[0]; + Assert.Single(skill.Resources!); + Assert.Equal("data.json", skill.Resources![0].Name); } [Fact] - public void DiscoverAndLoadSkills_SkillMdFile_NotIncludedAsResource() + public async Task GetSkillsAsync_SkillMdFile_NotIncludedAsResourceAsync() { // Arrange — the SKILL.md file itself should not be in the resource list string skillDir = Path.Combine(this._testRoot, "selfref-skill"); @@ -244,19 +254,20 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: selfref-skill\ndescription: Self ref test\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - var skill = skills["selfref-skill"]; - Assert.Single(skill.ResourceNames); - Assert.Equal("notes.md", skill.ResourceNames[0]); + var skill = skills[0]; + Assert.Single(skill.Resources!); + Assert.Equal("notes.md", skill.Resources![0].Name); } [Fact] - public void DiscoverAndLoadSkills_NestedResourceFiles_Discovered() + public async Task GetSkillsAsync_NestedResourceFiles_DiscoveredAsync() { // Arrange — resource files in nested subdirectories string skillDir = Path.Combine(this._testRoot, "nested-res-skill"); @@ -266,26 +277,22 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: nested-res-skill\ndescription: Nested resources\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - var skill = skills["nested-res-skill"]; - Assert.Single(skill.ResourceNames); - Assert.Contains(skill.ResourceNames, r => r.Equals("level1/level2/deep.md", StringComparison.OrdinalIgnoreCase)); + var skill = skills[0]; + Assert.Single(skill.Resources!); + Assert.Contains(skill.Resources!, r => r.Name.Equals("level1/level2/deep.md", StringComparison.OrdinalIgnoreCase)); } - private static readonly string[] s_customExtensions = new[] { ".custom" }; - private static readonly string[] s_validExtensions = new[] { ".md", ".json", ".custom" }; - private static readonly string[] s_mixedValidInvalidExtensions = new[] { ".md", "json" }; - [Fact] - public void DiscoverAndLoadSkills_CustomResourceExtensions_UsedForDiscovery() + public async Task GetSkillsAsync_CustomResourceExtensions_UsedForDiscoveryAsync() { - // Arrange — use a loader with custom extensions - var customLoader = new FileAgentSkillLoader(NullLogger.Instance, s_customExtensions); + // Arrange — use a source with custom extensions string skillDir = Path.Combine(this._testRoot, "custom-ext-skill"); Directory.CreateDirectory(skillDir); File.WriteAllText(Path.Combine(skillDir, "data.custom"), "custom data"); @@ -293,15 +300,16 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: custom-ext-skill\ndescription: Custom extensions\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = s_customExtensions }); // Act - var skills = customLoader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert — only .custom files should be discovered, not .json Assert.Single(skills); - var skill = skills["custom-ext-skill"]; - Assert.Single(skill.ResourceNames); - Assert.Equal("data.custom", skill.ResourceNames[0]); + var skill = skills[0]; + Assert.Single(skill.Resources!); + Assert.Equal("data.custom", skill.Resources![0].Name); } [Theory] @@ -311,39 +319,39 @@ public sealed class FileAgentSkillLoaderTests : IDisposable public void Constructor_InvalidExtension_ThrowsArgumentException(string badExtension) { // Arrange & Act & Assert - Assert.Throws(() => new FileAgentSkillLoader(NullLogger.Instance, new[] { badExtension })); + Assert.Throws(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = new string[] { badExtension } })); } [Fact] - public void Constructor_NullExtensions_UsesDefaults() + public async Task Constructor_NullExtensions_UsesDefaultsAsync() { // Arrange & Act - var loader = new FileAgentSkillLoader(NullLogger.Instance, null); string skillDir = this.CreateSkillDirectory("null-ext", "A skill", "Body."); File.WriteAllText(Path.Combine(skillDir, "notes.md"), "notes"); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Assert — default extensions include .md - var skills = loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - Assert.Single(skills["null-ext"].ResourceNames); + var skills = await source.GetSkillsAsync(); + Assert.Single(skills[0].Resources!); } [Fact] public void Constructor_ValidExtensions_DoesNotThrow() { // Arrange & Act & Assert — should not throw - var loader = new FileAgentSkillLoader(NullLogger.Instance, s_validExtensions); - Assert.NotNull(loader); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = s_validExtensions }); + Assert.NotNull(source); } [Fact] public void Constructor_MixOfValidAndInvalidExtensions_ThrowsArgumentException() { // Arrange & Act & Assert — one bad extension in the list should cause failure - Assert.Throws(() => new FileAgentSkillLoader(NullLogger.Instance, s_mixedValidInvalidExtensions)); + Assert.Throws(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { AllowedResourceExtensions = s_mixedValidInvalidExtensions })); } [Fact] - public void DiscoverAndLoadSkills_ResourceInSkillRoot_Discovered() + public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredAsync() { // Arrange — resource file directly in the skill directory (not in a subdirectory) string skillDir = Path.Combine(this._testRoot, "root-resource-skill"); @@ -353,54 +361,62 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: root-resource-skill\ndescription: Root resources\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert — both root-level resource files should be discovered Assert.Single(skills); - var skill = skills["root-resource-skill"]; - Assert.Equal(2, skill.ResourceNames.Count); - Assert.Contains(skill.ResourceNames, r => r.Equals("guide.md", StringComparison.OrdinalIgnoreCase)); - Assert.Contains(skill.ResourceNames, r => r.Equals("config.json", StringComparison.OrdinalIgnoreCase)); + var skill = skills[0]; + Assert.Equal(2, skill.Resources!.Count); + Assert.Contains(skill.Resources!, r => r.Name.Equals("guide.md", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(skill.Resources!, r => r.Name.Equals("config.json", StringComparison.OrdinalIgnoreCase)); } [Fact] - public void DiscoverAndLoadSkills_NoResourceFiles_ReturnsEmptyResourceNames() + public async Task GetSkillsAsync_NoResourceFiles_ReturnsEmptyResourcesAsync() { // Arrange — skill with no resource files _ = this.CreateSkillDirectory("no-resources", "A skill", "No resources here."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - Assert.Empty(skills["no-resources"].ResourceNames); + Assert.Empty(skills[0].Resources!); } [Fact] - public void DiscoverAndLoadSkills_EmptyPaths_ReturnsEmptyDictionary() + public async Task GetSkillsAsync_EmptyPaths_ReturnsEmptyListAsync() { + // Arrange + var source = new AgentFileSkillsSource(Enumerable.Empty(), s_noOpExecutor); + // Act - var skills = this._loader.DiscoverAndLoadSkills(Enumerable.Empty()); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_NonExistentPath_ReturnsEmptyDictionary() + public async Task GetSkillsAsync_NonExistentPath_ReturnsEmptyListAsync() { + // Arrange + var source = new AgentFileSkillsSource(Path.Combine(this._testRoot, "does-not-exist"), s_noOpExecutor); + // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { Path.Combine(this._testRoot, "does-not-exist") }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_NestedSkillDirectory_DiscoveredWithinDepthLimit() + public async Task GetSkillsAsync_NestedSkillDirectory_DiscoveredWithinDepthLimitAsync() { // Arrange — nested 1 level deep (MaxSearchDepth = 2, so depth 0 = testRoot, depth 1 = level1) string nestedDir = Path.Combine(this._testRoot, "level1", "nested-skill"); @@ -408,13 +424,14 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(nestedDir, "SKILL.md"), "---\nname: nested-skill\ndescription: Nested\n---\nNested body."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - Assert.True(skills.ContainsKey("nested-skill")); + Assert.Equal("nested-skill", skills[0].Frontmatter.Name); } [Fact] @@ -425,54 +442,19 @@ public sealed class FileAgentSkillLoaderTests : IDisposable string refsDir = Path.Combine(skillDir, "refs"); Directory.CreateDirectory(refsDir); File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Document content here."); - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - var skill = skills["read-skill"]; + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + var skills = await source.GetSkillsAsync(); + var resource = skills[0].Resources!.First(r => r.Name == "refs/doc.md"); // Act - string content = await this._loader.ReadSkillResourceAsync(skill, "refs/doc.md"); + var content = await resource.ReadAsync(); // Assert Assert.Equal("Document content here.", content); } [Fact] - public async Task ReadSkillResourceAsync_UnregisteredResource_ThrowsInvalidOperationExceptionAsync() - { - // Arrange - string skillDir = this.CreateSkillDirectory("simple-skill", "A skill", "No resources."); - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - var skill = skills["simple-skill"]; - - // Act & Assert - await Assert.ThrowsAsync( - () => this._loader.ReadSkillResourceAsync(skill, "unknown.md")); - } - - [Fact] - public async Task ReadSkillResourceAsync_PathTraversal_ThrowsInvalidOperationExceptionAsync() - { - // Arrange — skill with a legitimate resource, then try to read a traversal path at read time - string skillDir = this.CreateSkillDirectory("traverse-read", "A skill", "See docs."); - string refsDir = Path.Combine(skillDir, "refs"); - Directory.CreateDirectory(refsDir); - File.WriteAllText(Path.Combine(refsDir, "doc.md"), "legit"); - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - var skill = skills["traverse-read"]; - - // Manually construct a skill with the traversal resource in its list to bypass discovery validation - var tampered = new FileAgentSkill( - skill.Frontmatter, - skill.Body, - skill.SourcePath, - s_traversalResource); - - // Act & Assert - await Assert.ThrowsAsync( - () => this._loader.ReadSkillResourceAsync(tampered, "../secret.txt")); - } - - [Fact] - public void DiscoverAndLoadSkills_NameExceedsMaxLength_ExcludesSkill() + public async Task GetSkillsAsync_NameExceedsMaxLength_ExcludesSkillAsync() { // Arrange — name longer than 64 characters string longName = new('a', 65); @@ -481,16 +463,17 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), $"---\nname: {longName}\ndescription: A skill\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } [Fact] - public void DiscoverAndLoadSkills_DescriptionExceedsMaxLength_ExcludesSkill() + public async Task GetSkillsAsync_DescriptionExceedsMaxLength_ExcludesSkillAsync() { // Arrange — description longer than 1024 characters string longDesc = new('x', 1025); @@ -499,71 +482,18 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), $"---\nname: long-desc\ndescription: {longDesc}\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Empty(skills); } - [Fact] - public async Task ReadSkillResourceAsync_DotSlashPrefix_MatchesNormalizedResourceAsync() - { - // Arrange — skill loaded with bare path, caller uses ./ prefix - string skillDir = this.CreateSkillDirectory("dotslash-read", "A skill", "See docs."); - string refsDir = Path.Combine(skillDir, "refs"); - Directory.CreateDirectory(refsDir); - File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Document content."); - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - var skill = skills["dotslash-read"]; - - // Act — caller passes ./refs/doc.md which should match refs/doc.md - string content = await this._loader.ReadSkillResourceAsync(skill, "./refs/doc.md"); - - // Assert - Assert.Equal("Document content.", content); - } - - [Fact] - public async Task ReadSkillResourceAsync_BackslashSeparator_MatchesNormalizedResourceAsync() - { - // Arrange — skill loaded with forward-slash path, caller uses backslashes - string skillDir = this.CreateSkillDirectory("backslash-read", "A skill", "See docs."); - string refsDir = Path.Combine(skillDir, "refs"); - Directory.CreateDirectory(refsDir); - File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Backslash content."); - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - var skill = skills["backslash-read"]; - - // Act — caller passes refs\doc.md which should match refs/doc.md - string content = await this._loader.ReadSkillResourceAsync(skill, "refs\\doc.md"); - - // Assert - Assert.Equal("Backslash content.", content); - } - - [Fact] - public async Task ReadSkillResourceAsync_DotSlashWithBackslash_MatchesNormalizedResourceAsync() - { - // Arrange — skill loaded with forward-slash path, caller uses .\ prefix with backslashes - string skillDir = this.CreateSkillDirectory("mixed-sep-read", "A skill", "See docs."); - string refsDir = Path.Combine(skillDir, "refs"); - Directory.CreateDirectory(refsDir); - File.WriteAllText(Path.Combine(refsDir, "doc.md"), "Mixed separator content."); - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); - var skill = skills["mixed-sep-read"]; - - // Act — caller passes .\refs\doc.md which should match refs/doc.md - string content = await this._loader.ReadSkillResourceAsync(skill, ".\\refs\\doc.md"); - - // Assert - Assert.Equal("Mixed separator content.", content); - } - #if NET [Fact] - public void DiscoverAndLoadSkills_SymlinkInPath_SkipsSymlinkedResources() + public async Task GetSkillsAsync_SymlinkInPath_SkipsSymlinkedResourcesAsync() { // Arrange — a "refs" subdirectory is a symlink pointing outside the skill directory string skillDir = Path.Combine(this._testRoot, "symlink-escape-skill"); @@ -588,71 +518,179 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: symlink-escape-skill\ndescription: Symlinked directory escape\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert — skill should still load, but symlinked resources should be excluded - Assert.True(skills.ContainsKey("symlink-escape-skill")); - var skill = skills["symlink-escape-skill"]; - Assert.Single(skill.ResourceNames); - Assert.Equal("legit.md", skill.ResourceNames[0]); - } - - private static readonly string[] s_symlinkResource = ["refs/data.md"]; - - [Fact] - public async Task ReadSkillResourceAsync_SymlinkInPath_ThrowsInvalidOperationExceptionAsync() - { - // Arrange — build a skill with a symlinked subdirectory - string skillDir = Path.Combine(this._testRoot, "symlink-read-skill"); - string refsDir = Path.Combine(skillDir, "refs"); - Directory.CreateDirectory(skillDir); - - string outsideDir = Path.Combine(this._testRoot, "outside-read"); - Directory.CreateDirectory(outsideDir); - File.WriteAllText(Path.Combine(outsideDir, "data.md"), "external data"); - - try - { - Directory.CreateSymbolicLink(refsDir, outsideDir); - } - catch (IOException) - { - // Symlink creation requires elevation on some platforms; skip gracefully. - return; - } - - // Manually construct a skill that bypasses discovery validation - var frontmatter = new SkillFrontmatter("symlink-read-skill", "A skill"); - var skill = new FileAgentSkill( - frontmatter: frontmatter, - body: "See [doc](refs/data.md).", - sourcePath: skillDir, - resourceNames: s_symlinkResource); - - // Act & Assert - await Assert.ThrowsAsync( - () => this._loader.ReadSkillResourceAsync(skill, "refs/data.md")); + var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-escape-skill"); + Assert.NotNull(skill); + Assert.Single(skill.Resources!); + Assert.Equal("legit.md", skill.Resources![0].Name); } #endif [Fact] - public void DiscoverAndLoadSkills_FileWithUtf8Bom_ParsesSuccessfully() + public async Task GetSkillsAsync_FileWithUtf8Bom_ParsesSuccessfullyAsync() { // Arrange — prepend a UTF-8 BOM (\uFEFF) before the frontmatter _ = this.CreateSkillDirectoryWithRawContent( "bom-skill", "\uFEFF---\nname: bom-skill\ndescription: Skill with BOM\n---\nBody content."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act - var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skills = await source.GetSkillsAsync(); // Assert Assert.Single(skills); - Assert.True(skills.ContainsKey("bom-skill")); - Assert.Equal("Skill with BOM", skills["bom-skill"].Frontmatter.Description); - Assert.Equal("Body content.", skills["bom-skill"].Body); + Assert.Equal("bom-skill", skills[0].Frontmatter.Name); + Assert.Equal("Skill with BOM", skills[0].Frontmatter.Description); + } + + [Fact] + public async Task GetSkillsAsync_LicenseField_ParsedCorrectlyAsync() + { + // Arrange + _ = this.CreateSkillDirectoryWithRawContent( + "licensed-skill", + "---\nname: licensed-skill\ndescription: A skill with license\nlicense: MIT\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + Assert.Equal("MIT", skills[0].Frontmatter.License); + } + + [Fact] + public async Task GetSkillsAsync_CompatibilityField_ParsedCorrectlyAsync() + { + // Arrange + _ = this.CreateSkillDirectoryWithRawContent( + "compat-skill", + "---\nname: compat-skill\ndescription: A skill with compatibility\ncompatibility: Requires Node.js 18+\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + Assert.Equal("Requires Node.js 18+", skills[0].Frontmatter.Compatibility); + } + + [Fact] + public async Task GetSkillsAsync_AllowedToolsField_ParsedCorrectlyAsync() + { + // Arrange + _ = this.CreateSkillDirectoryWithRawContent( + "tools-skill", + "---\nname: tools-skill\ndescription: A skill with tools\nallowed-tools: grep glob bash\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + Assert.Equal("grep glob bash", skills[0].Frontmatter.AllowedTools); + } + + [Fact] + public async Task GetSkillsAsync_MetadataField_ParsedCorrectlyAsync() + { + // Arrange + _ = this.CreateSkillDirectoryWithRawContent( + "meta-skill", + "---\nname: meta-skill\ndescription: A skill with metadata\nmetadata:\n author: test-user\n version: 1.0\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + Assert.NotNull(skills[0].Frontmatter.Metadata); + Assert.Equal("test-user", skills[0].Frontmatter.Metadata!["author"]?.ToString()); + Assert.Equal("1.0", skills[0].Frontmatter.Metadata!["version"]?.ToString()); + } + + [Fact] + public async Task GetSkillsAsync_MetadataWithQuotedValues_ParsedCorrectlyAsync() + { + // Arrange + _ = this.CreateSkillDirectoryWithRawContent( + "quoted-meta", + "---\nname: quoted-meta\ndescription: Metadata with quotes\nmetadata:\n key1: 'single quoted'\n key2: \"double quoted\"\n---\nBody."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + Assert.NotNull(skills[0].Frontmatter.Metadata); + Assert.Equal("single quoted", skills[0].Frontmatter.Metadata!["key1"]?.ToString()); + Assert.Equal("double quoted", skills[0].Frontmatter.Metadata!["key2"]?.ToString()); + } + + [Fact] + public async Task GetSkillsAsync_AllOptionalFields_ParsedCorrectlyAsync() + { + // Arrange + string content = string.Join( + "\n", + "---", + "name: full-skill", + "description: A skill with all fields", + "license: Apache-2.0", + "compatibility: Requires Python 3.10+", + "allowed-tools: grep glob view", + "metadata:", + " org: contoso", + " tier: premium", + "---", + "Full body content."); + _ = this.CreateSkillDirectoryWithRawContent("full-skill", content); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + var fm = skills[0].Frontmatter; + Assert.Equal("full-skill", fm.Name); + Assert.Equal("A skill with all fields", fm.Description); + Assert.Equal("Apache-2.0", fm.License); + Assert.Equal("Requires Python 3.10+", fm.Compatibility); + Assert.Equal("grep glob view", fm.AllowedTools); + Assert.NotNull(fm.Metadata); + Assert.Equal("contoso", fm.Metadata!["org"]?.ToString()); + Assert.Equal("premium", fm.Metadata!["tier"]?.ToString()); + } + + [Fact] + public async Task GetSkillsAsync_NoOptionalFields_DefaultsToNullAsync() + { + // Arrange + _ = this.CreateSkillDirectory("basic-skill", "A basic skill", "Body."); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); + + // Act + var skills = await source.GetSkillsAsync(); + + // Assert + Assert.Single(skills); + var fm = skills[0].Frontmatter; + Assert.Null(fm.License); + Assert.Null(fm.Compatibility); + Assert.Null(fm.AllowedTools); + Assert.Null(fm.Metadata); } private string CreateSkillDirectory(string name, string description, string body) diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs deleted file mode 100644 index 5da49525d4..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs +++ /dev/null @@ -1,266 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI.UnitTests.AgentSkills; - -/// -/// Unit tests for the class. -/// -public sealed class FileAgentSkillsProviderTests : IDisposable -{ - private readonly string _testRoot; - private readonly TestAIAgent _agent = new(); - - public FileAgentSkillsProviderTests() - { - this._testRoot = Path.Combine(Path.GetTempPath(), "skills-provider-tests-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(this._testRoot); - } - - public void Dispose() - { - if (Directory.Exists(this._testRoot)) - { - Directory.Delete(this._testRoot, recursive: true); - } - } - - [Fact] - public async Task InvokingCoreAsync_NoSkills_ReturnsInputContextUnchangedAsync() - { - // Arrange - var provider = new FileAgentSkillsProvider(this._testRoot); - var inputContext = new AIContext { Instructions = "Original instructions" }; - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert - Assert.Equal("Original instructions", result.Instructions); - Assert.Null(result.Tools); - } - - [Fact] - public async Task InvokingCoreAsync_WithSkills_AppendsInstructionsAndToolsAsync() - { - // Arrange - this.CreateSkill("provider-skill", "Provider skill test", "Skill instructions body."); - var provider = new FileAgentSkillsProvider(this._testRoot); - var inputContext = new AIContext { Instructions = "Base instructions" }; - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert - Assert.NotNull(result.Instructions); - Assert.Contains("Base instructions", result.Instructions); - Assert.Contains("provider-skill", result.Instructions); - Assert.Contains("Provider skill test", result.Instructions); - - // Should have load_skill and read_skill_resource tools - Assert.NotNull(result.Tools); - var toolNames = result.Tools!.Select(t => t.Name).ToList(); - Assert.Contains("load_skill", toolNames); - Assert.Contains("read_skill_resource", toolNames); - } - - [Fact] - public async Task InvokingCoreAsync_NullInputInstructions_SetsInstructionsAsync() - { - // Arrange - this.CreateSkill("null-instr-skill", "Null instruction test", "Body."); - var provider = new FileAgentSkillsProvider(this._testRoot); - var inputContext = new AIContext(); - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert - Assert.NotNull(result.Instructions); - Assert.Contains("null-instr-skill", result.Instructions); - } - - [Fact] - public async Task InvokingCoreAsync_CustomPromptTemplate_UsesCustomTemplateAsync() - { - // Arrange - this.CreateSkill("custom-prompt-skill", "Custom prompt", "Body."); - var options = new FileAgentSkillsProviderOptions - { - SkillsInstructionPrompt = "Custom template: {0}" - }; - var provider = new FileAgentSkillsProvider(this._testRoot, options); - var inputContext = new AIContext(); - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert - Assert.NotNull(result.Instructions); - Assert.StartsWith("Custom template:", result.Instructions); - Assert.Contains("custom-prompt-skill", result.Instructions); - Assert.Contains("Custom prompt", result.Instructions); - } - - [Fact] - public void Constructor_InvalidPromptTemplate_ThrowsArgumentException() - { - // Arrange — template with unescaped braces and no valid {0} placeholder - var options = new FileAgentSkillsProviderOptions - { - SkillsInstructionPrompt = "Bad template with {unescaped} braces" - }; - - // Act & Assert - var ex = Assert.Throws(() => new FileAgentSkillsProvider(this._testRoot, options)); - Assert.Contains("SkillsInstructionPrompt", ex.Message); - Assert.Equal("options", ex.ParamName); - } - - [Fact] - public void Constructor_PromptWithoutPlaceholder_ThrowsArgumentException() - { - // Arrange -- valid format string but missing the required placeholder - var options = new FileAgentSkillsProviderOptions - { - SkillsInstructionPrompt = "No placeholder here" - }; - - var ex = Assert.Throws(() => new FileAgentSkillsProvider(this._testRoot, options)); - Assert.Contains("{0}", ex.Message); - Assert.Equal("options", ex.ParamName); - } - - [Fact] - public async Task Constructor_PromptWithPlaceholder_AppliesCustomTemplateAsync() - { - // Arrange — valid custom template with {0} placeholder - this.CreateSkill("custom-tpl-skill", "Custom template skill", "Body."); - var options = new FileAgentSkillsProviderOptions - { - SkillsInstructionPrompt = "== Skills ==\n{0}\n== End ==" - }; - var provider = new FileAgentSkillsProvider(this._testRoot, options); - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert — the custom template wraps the skill list - Assert.NotNull(result.Instructions); - Assert.StartsWith("== Skills ==", result.Instructions); - Assert.Contains("custom-tpl-skill", result.Instructions); - Assert.Contains("== End ==", result.Instructions); - } - - [Fact] - public async Task InvokingCoreAsync_SkillNamesAreXmlEscapedAsync() - { - // Arrange — description with XML-sensitive characters - string skillDir = Path.Combine(this._testRoot, "xml-skill"); - Directory.CreateDirectory(skillDir); - File.WriteAllText( - Path.Combine(skillDir, "SKILL.md"), - "---\nname: xml-skill\ndescription: Uses & \"quotes\"\n---\nBody."); - var provider = new FileAgentSkillsProvider(this._testRoot); - var inputContext = new AIContext(); - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert - Assert.NotNull(result.Instructions); - Assert.Contains("<tags>", result.Instructions); - Assert.Contains("&", result.Instructions); - } - - [Fact] - public async Task Constructor_WithMultiplePaths_LoadsFromAllAsync() - { - // Arrange - string dir1 = Path.Combine(this._testRoot, "dir1"); - string dir2 = Path.Combine(this._testRoot, "dir2"); - CreateSkillIn(dir1, "skill-a", "Skill A", "Body A."); - CreateSkillIn(dir2, "skill-b", "Skill B", "Body B."); - - // Act - var provider = new FileAgentSkillsProvider(new[] { dir1, dir2 }); - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); - - // Assert - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - Assert.NotNull(result.Instructions); - Assert.Contains("skill-a", result.Instructions); - Assert.Contains("skill-b", result.Instructions); - } - - [Fact] - public async Task InvokingCoreAsync_PreservesExistingInputToolsAsync() - { - // Arrange - this.CreateSkill("tools-skill", "Tools test", "Body."); - var provider = new FileAgentSkillsProvider(this._testRoot); - - var existingTool = AIFunctionFactory.Create(() => "test", name: "existing_tool", description: "An existing tool."); - var inputContext = new AIContext { Tools = new[] { existingTool } }; - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert — existing tool should be preserved alongside the new skill tools - Assert.NotNull(result.Tools); - var toolNames = result.Tools!.Select(t => t.Name).ToList(); - Assert.Contains("existing_tool", toolNames); - Assert.Contains("load_skill", toolNames); - Assert.Contains("read_skill_resource", toolNames); - } - - [Fact] - public async Task InvokingCoreAsync_SkillsListIsSortedByNameAsync() - { - // Arrange — create skills in reverse alphabetical order - this.CreateSkill("zulu-skill", "Zulu skill", "Body Z."); - this.CreateSkill("alpha-skill", "Alpha skill", "Body A."); - this.CreateSkill("mike-skill", "Mike skill", "Body M."); - var provider = new FileAgentSkillsProvider(this._testRoot); - var inputContext = new AIContext(); - var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); - - // Act - var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert — skills should appear in alphabetical order in the prompt - Assert.NotNull(result.Instructions); - int alphaIndex = result.Instructions!.IndexOf("alpha-skill", StringComparison.Ordinal); - int mikeIndex = result.Instructions.IndexOf("mike-skill", StringComparison.Ordinal); - int zuluIndex = result.Instructions.IndexOf("zulu-skill", StringComparison.Ordinal); - Assert.True(alphaIndex < mikeIndex, "alpha-skill should appear before mike-skill"); - Assert.True(mikeIndex < zuluIndex, "mike-skill should appear before zulu-skill"); - } - - private void CreateSkill(string name, string description, string body) - { - CreateSkillIn(this._testRoot, name, description, body); - } - - private static void CreateSkillIn(string root, string name, string description, string body) - { - string skillDir = Path.Combine(root, name); - Directory.CreateDirectory(skillDir); - File.WriteAllText( - Path.Combine(skillDir, "SKILL.md"), - $"---\nname: {name}\ndescription: {description}\n---\n{body}"); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs new file mode 100644 index 0000000000..de145004e0 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FilteringAgentSkillsSourceTests.cs @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class FilteringAgentSkillsSourceTests +{ + [Fact] + public async Task GetSkillsAsync_PredicateIncludesAll_ReturnsAllSkillsAsync() + { + // Arrange + var inner = new TestAgentSkillsSource( + new TestAgentSkill("skill-a", "A", "Instructions A."), + new TestAgentSkill("skill-b", "B", "Instructions B.")); + var source = new FilteringAgentSkillsSource(inner, _ => true); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Equal(2, result.Count); + } + + [Fact] + public async Task GetSkillsAsync_PredicateExcludesAll_ReturnsEmptyAsync() + { + // Arrange + var inner = new TestAgentSkillsSource( + new TestAgentSkill("skill-a", "A", "Instructions A."), + new TestAgentSkill("skill-b", "B", "Instructions B.")); + var source = new FilteringAgentSkillsSource(inner, _ => false); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Empty(result); + } + + [Fact] + public async Task GetSkillsAsync_PartialFilter_ReturnsMatchingSkillsOnlyAsync() + { + // Arrange + var inner = new TestAgentSkillsSource( + new TestAgentSkill("keep-me", "Keep", "Instructions."), + new TestAgentSkill("drop-me", "Drop", "Instructions."), + new TestAgentSkill("keep-also", "KeepAlso", "Instructions.")); + var source = new FilteringAgentSkillsSource( + inner, + skill => skill.Frontmatter.Name.StartsWith("keep", StringComparison.OrdinalIgnoreCase)); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Equal(2, result.Count); + Assert.All(result, s => Assert.StartsWith("keep", s.Frontmatter.Name)); + } + + [Fact] + public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync() + { + // Arrange + var inner = new TestAgentSkillsSource(Array.Empty()); + var source = new FilteringAgentSkillsSource(inner, _ => true); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Empty(result); + } + + [Fact] + public void Constructor_NullPredicate_Throws() + { + // Arrange + var inner = new TestAgentSkillsSource(Array.Empty()); + + // Act & Assert + Assert.Throws(() => new FilteringAgentSkillsSource(inner, null!)); + } + + [Fact] + public void Constructor_NullInnerSource_Throws() + { + // Act & Assert + Assert.Throws(() => new FilteringAgentSkillsSource(null!, _ => true)); + } + + [Fact] + public async Task GetSkillsAsync_PreservesOrderAsync() + { + // Arrange + var inner = new TestAgentSkillsSource( + new TestAgentSkill("alpha", "Alpha", "Instructions."), + new TestAgentSkill("beta", "Beta", "Instructions."), + new TestAgentSkill("gamma", "Gamma", "Instructions."), + new TestAgentSkill("delta", "Delta", "Instructions.")); + + // Keep only alpha and gamma + var source = new FilteringAgentSkillsSource( + inner, + skill => skill.Frontmatter.Name is "alpha" or "gamma"); + + // Act + var result = await source.GetSkillsAsync(CancellationToken.None); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("alpha", result[0].Frontmatter.Name); + Assert.Equal("gamma", result[1].Frontmatter.Name); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/TestSkillTypes.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/TestSkillTypes.cs new file mode 100644 index 0000000000..8c97a31ae4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/TestSkillTypes.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// A simple in-memory implementation for unit tests. +/// +internal sealed class TestAgentSkill : AgentSkill +{ + private readonly AgentSkillFrontmatter _frontmatter; + private readonly string _content; + + /// + /// Initializes a new instance of the class. + /// + /// Kebab-case skill name. + /// Skill description. + /// Full skill content (body text). + public TestAgentSkill(string name, string description, string content) + { + this._frontmatter = new AgentSkillFrontmatter(name, description); + this._content = content; + } + + /// + public override AgentSkillFrontmatter Frontmatter => this._frontmatter; + + /// + public override string Content => this._content; + + /// + public override IReadOnlyList? Resources => null; + + /// + public override IReadOnlyList? Scripts => null; +} + +/// +/// A simple in-memory implementation for unit tests. +/// +internal sealed class TestAgentSkillsSource : AgentSkillsSource +{ + private readonly IList _skills; + + /// + /// Initializes a new instance of the class. + /// + /// The skills to return. + public TestAgentSkillsSource(IList skills) + { + this._skills = skills; + } + + /// + /// Initializes a new instance of the class. + /// + /// The skills to return. + public TestAgentSkillsSource(params AgentSkill[] skills) + { + this._skills = skills; + } + + /// + public override Task> GetSkillsAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(this._skills); + } +} From 3611be82cf034d598278861ff246dace86d15a19 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 12:43:25 +0900 Subject: [PATCH 18/22] Bump flatted from 3.3.3 to 3.4.2 in /python/packages/devui/frontend (#4805) Bumps [flatted](https://github.com/WebReflection/flatted) from 3.3.3 to 3.4.2. - [Commits](https://github.com/WebReflection/flatted/compare/v3.3.3...v3.4.2) --- updated-dependencies: - dependency-name: flatted dependency-version: 3.4.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- python/packages/devui/frontend/package-lock.json | 6 +++--- python/packages/devui/frontend/yarn.lock | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/python/packages/devui/frontend/package-lock.json b/python/packages/devui/frontend/package-lock.json index 4a43bcd90f..e137c1053c 100644 --- a/python/packages/devui/frontend/package-lock.json +++ b/python/packages/devui/frontend/package-lock.json @@ -3892,9 +3892,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, diff --git a/python/packages/devui/frontend/yarn.lock b/python/packages/devui/frontend/yarn.lock index c0278b182e..24636d2146 100644 --- a/python/packages/devui/frontend/yarn.lock +++ b/python/packages/devui/frontend/yarn.lock @@ -1806,9 +1806,9 @@ flat-cache@^4.0.0: keyv "^4.5.4" flatted@^3.2.9: - version "3.3.3" - resolved "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz" - integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== + version "3.4.2" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726" + integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA== fsevents@~2.3.2, fsevents@~2.3.3: version "2.3.3" From cc0cfaaac8e17cea6d4d9af2783376e858ad7169 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 27 Mar 2026 14:33:39 +0100 Subject: [PATCH 19/22] [BREAKING] Python: fix OpenAI Azure routing and provider samples (#4925) * Python: fix OpenAI Azure routing and provider samples Prefer OpenAI when OPENAI_API_KEY is present unless Azure is explicitly requested. Clarify constructor docs, keep deprecated Azure wrappers compatible with stricter settings validation, and refresh the provider samples and tests to use the current client patterns. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix bandit * Python: align OpenAI embedding Azure routing Extend the shared OpenAI-vs-Azure routing and credential behavior to the embedding client, add Azure embedding regression coverage, and refresh the embedding samples to use the generic client path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: fix embedding client pyright check Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: thin OpenAI embedding wrapper Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: document embedding overload routing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: fix callable OpenAI key routing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: fix Azure credential routing tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: address OpenAI review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: narrow Azure routing markers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: refine OpenAI model fallback order Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: narrow Azure deployment docs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: remove embedding routing wording Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: run embedding Azure integration tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * changed variable name * Python: expand OpenAI package README Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * clarified readme * Python: fix Azure OpenAI integration setup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: correct Azure integration env mapping Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * updated code to fix int tests * test updates * test fix * fix test setup * updates to tests and setup * remove openai assistants int tests * improvements in int tests * fix env var * fix env vars * fix azure responses test * trigger actions --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workflows/python-integration-tests.yml | 46 +- .github/workflows/python-merge-tests.yml | 44 +- python/.env.example | 8 +- python/DEV_SETUP.md | 4 +- python/README.md | 20 +- .../_deprecated_azure_openai.py | 85 +- .../azure_openai/test_azure_chat_client.py | 39 + .../test_azure_embedding_client.py | 83 +- .../test_azure_responses_client.py | 389 +++------ .../test_azure_responses_client_foundry.py | 131 +++ .../azure-ai/tests/test_azure_ai_client.py | 18 +- python/packages/core/README.md | 4 +- .../agent_framework_devui/ui/assets/index.js | 7 +- python/packages/devui/dev.md | 2 +- .../components/layout/deployment-modal.tsx | 4 +- .../agent_framework_foundry/__init__.py | 7 +- .../{_foundry_agent_client.py => _agent.py} | 293 ++++++- ...foundry_chat_client.py => _chat_client.py} | 61 +- .../_entra_id_authentication.py | 67 -- .../agent_framework_foundry/_foundry_agent.py | 287 ------- ...memory_provider.py => _memory_provider.py} | 31 +- .../agent_framework_foundry/_shared.py | 49 -- .../foundry/tests/assets/sample_image.jpg | Bin 0 -> 182161 bytes .../foundry/tests/{ => foundry}/conftest.py | 0 .../tests/foundry/test_foundry_agent.py | 413 ++++++++++ .../tests/foundry/test_foundry_chat_client.py | 751 ++++++++++++++++++ .../foundry/test_foundry_memory_provider.py | 501 ++++++++++++ .../foundry/tests/test_foundry_agent.py | 374 --------- .../tests/test_foundry_memory_provider.py | 507 ------------ .../packages/lab/gaia/samples/openai_agent.py | 4 +- python/packages/openai/AGENTS.md | 4 + python/packages/openai/README.md | 97 ++- .../agent_framework_openai/_chat_client.py | 400 ++++++---- .../_chat_completion_client.py | 523 +++++++----- .../_embedding_client.py | 418 +++++++--- .../openai/agent_framework_openai/_shared.py | 315 +++++--- .../packages/openai/tests/openai/conftest.py | 13 + .../tests/openai/test_assistant_provider.py | 62 -- .../tests/openai/test_openai_chat_client.py | 348 ++++---- .../openai/test_openai_chat_client_azure.py | 257 +++--- .../test_openai_chat_completion_client.py | 130 ++- ...est_openai_chat_completion_client_azure.py | 186 +++-- .../openai/test_openai_embedding_client.py | 51 +- .../test_openai_embedding_client_azure.py | 250 ++++++ .../openai/tests/openai/test_openai_shared.py | 54 ++ .../samples/02-agents/chat_client/README.md | 4 +- .../search_context_semantic.py | 17 +- python/samples/02-agents/devui/README.md | 2 +- .../embeddings/azure_openai_embeddings.py | 74 +- .../02-agents/embeddings/openai_embeddings.py | 21 +- python/samples/02-agents/mcp/README.md | 2 +- python/samples/02-agents/middleware/README.md | 2 +- .../02-agents/observability/.env.example | 4 +- .../02-agents/providers/azure/README.md | 48 +- ...=> openai_chat_completion_client_basic.py} | 33 +- ...mpletion_client_with_explicit_settings.py} | 21 +- ..._completion_client_with_function_tools.py} | 0 ...ai_chat_completion_client_with_session.py} | 0 .../providers/azure/openai_client_basic.py | 90 +++ .../openai_client_with_function_tools.py | 137 ++++ .../azure/openai_client_with_session.py | 152 ++++ .../openai_client_with_structured_output.py | 93 +++ .../02-agents/providers/custom/README.md | 6 +- .../02-agents/providers/openai/README.md | 102 ++- .../openai/chat_completion_client_basic.py | 85 ++ ...mpletion_client_with_explicit_settings.py} | 18 +- ..._completion_client_with_function_tools.py} | 14 +- ... chat_completion_client_with_local_mcp.py} | 12 +- ...letion_client_with_runtime_json_schema.py} | 10 +- ...=> chat_completion_client_with_session.py} | 16 +- ...chat_completion_client_with_web_search.py} | 8 +- ...i_chat_client_basic.py => client_basic.py} | 26 +- ...e_analysis.py => client_image_analysis.py} | 12 +- ...neration.py => client_image_generation.py} | 10 +- ...lient_reasoning.py => client_reasoning.py} | 8 +- ...y => client_streaming_image_generation.py} | 6 +- ...s_tool.py => client_with_agent_as_tool.py} | 8 +- ...ter.py => client_with_code_interpreter.py} | 12 +- ... => client_with_code_interpreter_files.py} | 10 +- ...gs.py => client_with_explicit_settings.py} | 0 ...e_search.py => client_with_file_search.py} | 12 +- ...tools.py => client_with_function_tools.py} | 0 ...osted_mcp.py => client_with_hosted_mcp.py} | 16 +- ..._local_mcp.py => client_with_local_mcp.py} | 12 +- ...al_shell.py => client_with_local_shell.py} | 6 +- ....py => client_with_runtime_json_schema.py} | 4 +- ...with_session.py => client_with_session.py} | 14 +- ...ent_with_shell.py => client_with_shell.py} | 12 +- ...ut.py => client_with_structured_output.py} | 16 +- ...eb_search.py => client_with_web_search.py} | 8 +- .../openai/openai_assistants_basic.py | 98 --- .../openai_assistants_provider_methods.py | 158 ---- ...openai_assistants_with_code_interpreter.py | 81 -- ...enai_assistants_with_existing_assistant.py | 118 --- ...penai_assistants_with_explicit_settings.py | 61 -- .../openai_assistants_with_file_search.py | 78 -- .../openai_assistants_with_function_tools.py | 159 ---- .../openai_assistants_with_response_format.py | 96 --- .../openai/openai_assistants_with_session.py | 172 ---- .../openai/openai_responses_client_basic.py | 132 --- .../05-end-to-end/m365-agent/.env.example | 2 +- .../05-end-to-end/m365-agent/README.md | 2 +- python/samples/README.md | 10 + 103 files changed, 5451 insertions(+), 4216 deletions(-) create mode 100644 python/packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py rename python/packages/foundry/agent_framework_foundry/{_foundry_agent_client.py => _agent.py} (57%) rename python/packages/foundry/agent_framework_foundry/{_foundry_chat_client.py => _chat_client.py} (92%) delete mode 100644 python/packages/foundry/agent_framework_foundry/_entra_id_authentication.py delete mode 100644 python/packages/foundry/agent_framework_foundry/_foundry_agent.py rename python/packages/foundry/agent_framework_foundry/{_foundry_memory_provider.py => _memory_provider.py} (94%) delete mode 100644 python/packages/foundry/agent_framework_foundry/_shared.py create mode 100644 python/packages/foundry/tests/assets/sample_image.jpg rename python/packages/foundry/tests/{ => foundry}/conftest.py (100%) create mode 100644 python/packages/foundry/tests/foundry/test_foundry_agent.py create mode 100644 python/packages/foundry/tests/foundry/test_foundry_chat_client.py create mode 100644 python/packages/foundry/tests/foundry/test_foundry_memory_provider.py delete mode 100644 python/packages/foundry/tests/test_foundry_agent.py delete mode 100644 python/packages/foundry/tests/test_foundry_memory_provider.py create mode 100644 python/packages/openai/tests/openai/test_openai_embedding_client_azure.py create mode 100644 python/packages/openai/tests/openai/test_openai_shared.py rename python/samples/02-agents/providers/azure/{openai_chat_completion_client_azure_basic.py => openai_chat_completion_client_basic.py} (70%) rename python/samples/02-agents/providers/azure/{openai_chat_completion_client_azure_with_explicit_settings.py => openai_chat_completion_client_with_explicit_settings.py} (71%) rename python/samples/02-agents/providers/azure/{openai_chat_completion_client_azure_with_function_tools.py => openai_chat_completion_client_with_function_tools.py} (100%) rename python/samples/02-agents/providers/azure/{openai_chat_completion_client_azure_with_session.py => openai_chat_completion_client_with_session.py} (100%) create mode 100644 python/samples/02-agents/providers/azure/openai_client_basic.py create mode 100644 python/samples/02-agents/providers/azure/openai_client_with_function_tools.py create mode 100644 python/samples/02-agents/providers/azure/openai_client_with_session.py create mode 100644 python/samples/02-agents/providers/azure/openai_client_with_structured_output.py create mode 100644 python/samples/02-agents/providers/openai/chat_completion_client_basic.py rename python/samples/02-agents/providers/openai/{openai_responses_client_with_explicit_settings.py => chat_completion_client_with_explicit_settings.py} (74%) rename python/samples/02-agents/providers/openai/{openai_responses_client_with_function_tools.py => chat_completion_client_with_function_tools.py} (91%) rename python/samples/02-agents/providers/openai/{openai_chat_client_with_local_mcp.py => chat_completion_client_with_local_mcp.py} (88%) rename python/samples/02-agents/providers/openai/{openai_responses_client_with_runtime_json_schema.py => chat_completion_client_with_runtime_json_schema.py} (89%) rename python/samples/02-agents/providers/openai/{openai_chat_client_with_session.py => chat_completion_client_with_session.py} (91%) rename python/samples/02-agents/providers/openai/{openai_chat_client_with_web_search.py => chat_completion_client_with_web_search.py} (86%) rename python/samples/02-agents/providers/openai/{openai_chat_client_basic.py => client_basic.py} (73%) rename python/samples/02-agents/providers/openai/{openai_responses_client_image_analysis.py => client_image_analysis.py} (70%) rename python/samples/02-agents/providers/openai/{openai_responses_client_image_generation.py => client_image_generation.py} (90%) rename python/samples/02-agents/providers/openai/{openai_responses_client_reasoning.py => client_reasoning.py} (91%) rename python/samples/02-agents/providers/openai/{openai_responses_client_streaming_image_generation.py => client_streaming_image_generation.py} (96%) rename python/samples/02-agents/providers/openai/{openai_responses_client_with_agent_as_tool.py => client_with_agent_as_tool.py} (90%) rename python/samples/02-agents/providers/openai/{openai_responses_client_with_code_interpreter.py => client_with_code_interpreter.py} (85%) rename python/samples/02-agents/providers/openai/{openai_responses_client_with_code_interpreter_files.py => client_with_code_interpreter_files.py} (92%) rename python/samples/02-agents/providers/openai/{openai_chat_client_with_explicit_settings.py => client_with_explicit_settings.py} (100%) rename python/samples/02-agents/providers/openai/{openai_responses_client_with_file_search.py => client_with_file_search.py} (85%) rename python/samples/02-agents/providers/openai/{openai_chat_client_with_function_tools.py => client_with_function_tools.py} (100%) rename python/samples/02-agents/providers/openai/{openai_responses_client_with_hosted_mcp.py => client_with_hosted_mcp.py} (95%) rename python/samples/02-agents/providers/openai/{openai_responses_client_with_local_mcp.py => client_with_local_mcp.py} (90%) rename python/samples/02-agents/providers/openai/{openai_responses_client_with_local_shell.py => client_with_local_shell.py} (96%) rename python/samples/02-agents/providers/openai/{openai_chat_client_with_runtime_json_schema.py => client_with_runtime_json_schema.py} (95%) rename python/samples/02-agents/providers/openai/{openai_responses_client_with_session.py => client_with_session.py} (93%) rename python/samples/02-agents/providers/openai/{openai_responses_client_with_shell.py => client_with_shell.py} (82%) rename python/samples/02-agents/providers/openai/{openai_responses_client_with_structured_output.py => client_with_structured_output.py} (87%) rename python/samples/02-agents/providers/openai/{openai_responses_client_with_web_search.py => client_with_web_search.py} (88%) delete mode 100644 python/samples/02-agents/providers/openai/openai_assistants_basic.py delete mode 100644 python/samples/02-agents/providers/openai/openai_assistants_provider_methods.py delete mode 100644 python/samples/02-agents/providers/openai/openai_assistants_with_code_interpreter.py delete mode 100644 python/samples/02-agents/providers/openai/openai_assistants_with_existing_assistant.py delete mode 100644 python/samples/02-agents/providers/openai/openai_assistants_with_explicit_settings.py delete mode 100644 python/samples/02-agents/providers/openai/openai_assistants_with_file_search.py delete mode 100644 python/samples/02-agents/providers/openai/openai_assistants_with_function_tools.py delete mode 100644 python/samples/02-agents/providers/openai/openai_assistants_with_response_format.py delete mode 100644 python/samples/02-agents/providers/openai/openai_assistants_with_session.py delete mode 100644 python/samples/02-agents/providers/openai/openai_responses_client_basic.py diff --git a/.github/workflows/python-integration-tests.yml b/.github/workflows/python-integration-tests.yml index 1b1c8066c6..2d12425312 100644 --- a/.github/workflows/python-integration-tests.yml +++ b/.github/workflows/python-integration-tests.yml @@ -60,9 +60,8 @@ jobs: environment: integration timeout-minutes: 60 env: - OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} - OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }} - OPENAI_EMBEDDINGS_MODEL_ID: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }} + OPENAI_CHAT_MODEL: ${{ vars.OPENAI__CHATMODELID }} + OPENAI_RESPONSES_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }} OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} @@ -96,10 +95,10 @@ jobs: environment: integration timeout-minutes: 60 env: - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__EMBEDDINGDEPLOYMENTNAME }} + AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME }} AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} defaults: run: @@ -126,7 +125,9 @@ jobs: uv run pytest --import-mode=importlib packages/openai/tests/openai/test_openai_chat_completion_client_azure.py packages/openai/tests/openai/test_openai_chat_client_azure.py + packages/openai/tests/openai/test_openai_embedding_client_azure.py packages/azure-ai/tests/azure_openai + --ignore=packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py -m integration -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread @@ -202,15 +203,15 @@ jobs: timeout-minutes: 60 env: UV_PYTHON: "3.11" - OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} - OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }} + OPENAI_CHAT_MODEL: ${{ vars.OPENAI__CHATMODELID }} + OPENAI_RESPONSES_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} - OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }} + OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - FOUNDRY_MODEL: ${{ vars.AZUREAI__DEPLOYMENTNAME }} - FOUNDRY_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }} + FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} FUNCTIONS_WORKER_RUNTIME: "python" DURABLE_TASK_SCHEDULER_CONNECTION_STRING: "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None" AzureWebJobsStorage: "UseDevelopmentStorage=true" @@ -248,17 +249,19 @@ jobs: --timeout=360 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 - # Azure AI integration tests - python-tests-azure-ai: - name: Python Integration Tests - Azure AI + # Foundry integration tests + python-tests-foundry: + name: Python Integration Tests - Foundry runs-on: ubuntu-latest environment: integration timeout-minutes: 60 env: AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }} AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREAI__DEPLOYMENTNAME }} - FOUNDRY_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }} - FOUNDRY_MODEL: ${{ vars.AZUREAI__DEPLOYMENTNAME }} + FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} + FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_AGENT_NAME }} + FOUNDRY_AGENT_VERSION: ${{ vars.FOUNDRY_AGENT_VERSION }} LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }} defaults: run: @@ -282,9 +285,14 @@ jobs: subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - name: Test with pytest timeout-minutes: 15 - run: | - uv run --directory packages/azure-ai poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 - uv run --directory packages/foundry poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 + run: > + uv run pytest --import-mode=importlib + packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py + packages/foundry/tests + -m integration + -n logical --dist worksteal + --timeout=120 --session-timeout=900 --timeout_method thread + --retries 2 --retry-delay 5 # Azure Cosmos integration tests python-tests-cosmos: @@ -341,7 +349,7 @@ jobs: python-tests-azure-openai, python-tests-misc-integration, python-tests-functions, - python-tests-azure-ai, + python-tests-foundry, python-tests-cosmos ] steps: diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml index a46beb40cb..f32fceccb5 100644 --- a/.github/workflows/python-merge-tests.yml +++ b/.github/workflows/python-merge-tests.yml @@ -141,9 +141,8 @@ jobs: runs-on: ubuntu-latest environment: integration env: - OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} - OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }} - OPENAI_EMBEDDINGS_MODEL_ID: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }} + OPENAI_CHAT_MODEL: ${{ vars.OPENAI__CHATMODELID }} + OPENAI_RESPONSES_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }} OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} @@ -195,10 +194,10 @@ jobs: runs-on: ubuntu-latest environment: integration env: - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__EMBEDDINGDEPLOYMENTNAME }} + AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME }} AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} defaults: run: @@ -223,7 +222,9 @@ jobs: uv run pytest --import-mode=importlib packages/openai/tests/openai/test_openai_chat_completion_client_azure.py packages/openai/tests/openai/test_openai_chat_client_azure.py + packages/openai/tests/openai/test_openai_embedding_client_azure.py packages/azure-ai/tests/azure_openai + --ignore=packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py -m integration -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread @@ -333,15 +334,15 @@ jobs: environment: integration env: UV_PYTHON: "3.11" - OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} - OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }} + OPENAI_CHAT_MODEL: ${{ vars.OPENAI__CHATMODELID }} + OPENAI_RESPONSES_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} - OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }} + OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - FOUNDRY_MODEL: ${{ vars.AZUREAI__DEPLOYMENTNAME }} - FOUNDRY_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }} + FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} FUNCTIONS_WORKER_RUNTIME: "python" DURABLE_TASK_SCHEDULER_CONNECTION_STRING: "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None" AzureWebJobsStorage: "UseDevelopmentStorage=true" @@ -387,8 +388,8 @@ jobs: fail-on-empty: false title: Functions integration test results - python-tests-azure-ai: - name: Python Tests - Azure AI + python-tests-foundry: + name: Python Integration Tests - Foundry needs: paths-filter if: > github.event_name != 'pull_request' && @@ -401,8 +402,10 @@ jobs: env: AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }} AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREAI__DEPLOYMENTNAME }} - FOUNDRY_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }} - FOUNDRY_MODEL: ${{ vars.AZUREAI__DEPLOYMENTNAME }} + FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} + FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_AGENT_NAME }} + FOUNDRY_AGENT_VERSION: ${{ vars.FOUNDRY_AGENT_VERSION }} LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }} defaults: run: @@ -424,9 +427,14 @@ jobs: subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - name: Test with pytest timeout-minutes: 15 - run: | - uv run --directory packages/azure-ai poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 - uv run --directory packages/foundry poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 + run: > + uv run pytest --import-mode=importlib + packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py + packages/foundry/tests + -m integration + -n logical --dist worksteal + --timeout=120 --session-timeout=900 --timeout_method thread + --retries 2 --retry-delay 5 working-directory: ./python - name: Test Azure AI samples timeout-minutes: 10 @@ -513,7 +521,7 @@ jobs: python-tests-azure-openai, python-tests-misc-integration, python-tests-functions, - python-tests-azure-ai, + python-tests-foundry, python-tests-cosmos, ] steps: diff --git a/python/.env.example b/python/.env.example index c09300d775..4e7ba727e5 100644 --- a/python/.env.example +++ b/python/.env.example @@ -1,6 +1,6 @@ # Azure AI -AZURE_AI_PROJECT_ENDPOINT="" -AZURE_AI_MODEL_DEPLOYMENT_NAME="" +FOUNDRY_PROJECT_ENDPOINT="" +FOUNDRY_MODEL="" # Bing connection for web search (optional, used by samples with web search) BING_CONNECTION_ID="" # Azure AI Search (optional, used by AzureAISearchContextProvider samples) @@ -13,8 +13,8 @@ AZURE_SEARCH_KNOWLEDGE_BASE_NAME="" # (different from AZURE_AI_PROJECT_ENDPOINT - Knowledge Base needs OpenAI endpoint for model calls) # OpenAI OPENAI_API_KEY="" -OPENAI_CHAT_MODEL_ID="" -OPENAI_RESPONSES_MODEL_ID="" +OPENAI_CHAT_MODEL="" +OPENAI_RESPONSES_MODEL="" # Azure OpenAI AZURE_OPENAI_ENDPOINT="" AZURE_OPENAI_CHAT_DEPLOYMENT_NAME="" diff --git a/python/DEV_SETUP.md b/python/DEV_SETUP.md index d90e29226d..dbddbaac93 100644 --- a/python/DEV_SETUP.md +++ b/python/DEV_SETUP.md @@ -108,10 +108,10 @@ Content of `.env` or `openai.env`: ```env OPENAI_API_KEY="" -OPENAI_CHAT_MODEL_ID="gpt-4o-mini" +OPENAI_MODEL="gpt-4o-mini" ``` -You will then configure the ChatClient class with the keyword argument `env_file_path`: +You will then configure the ChatClient class with the keyword argument `env_file_path` (alternatively you can use `load_dotenv` in your code): ```python from agent_framework.openai import OpenAIChatClient diff --git a/python/README.md b/python/README.md index f9350a08a4..0a3042992f 100644 --- a/python/README.md +++ b/python/README.md @@ -47,7 +47,7 @@ Set as environment variables, or create a .env file at your project root: ```bash OPENAI_API_KEY=sk-... -OPENAI_CHAT_MODEL_ID=... +OPENAI_MODEL=... ... AZURE_OPENAI_API_KEY=... AZURE_OPENAI_ENDPOINT=... @@ -57,15 +57,25 @@ FOUNDRY_PROJECT_ENDPOINT=... FOUNDRY_MODEL=... ``` +For the generic OpenAI clients (`OpenAIChatClient` and `OpenAIChatCompletionClient`), configuration +resolves in this order: + +1. Explicit Azure inputs such as `credential` or `azure_endpoint` +2. `OPENAI_API_KEY` / explicit OpenAI API-key parameters +3. Azure environment fallback such as `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_API_KEY` + +This means mixed shells default to OpenAI when `OPENAI_API_KEY` is present. To force Azure routing, +pass an explicit Azure input such as `credential=AzureCliCredential()`. + You can also override environment variables by explicitly passing configuration parameters to the chat client constructor: ```python -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatClient -client = AzureOpenAIChatClient( +client = OpenAIChatClient( api_key='', - endpoint='', - deployment_name='', + azure_endpoint='', + model='', api_version='', ) ``` diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py b/python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py index 8370412394..21f50e930a 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py @@ -13,6 +13,7 @@ import json import logging import sys from collections.abc import Mapping, Sequence +from contextlib import contextmanager from copy import copy from typing import TYPE_CHECKING, Any, ClassVar, Final, Generic, cast from urllib.parse import urljoin, urlparse @@ -109,6 +110,12 @@ def _apply_azure_defaults( settings["token_endpoint"] = default_token_endpoint +@contextmanager +def _prefer_single_azure_endpoint_env(*, endpoint: str | None, base_url: str | None) -> Any: + """Preserve the legacy call shape without mutating process-wide environment state.""" + yield + + # endregion @@ -315,6 +322,8 @@ class AzureOpenAIResponsesClient( # type: ignore[misc] "or 'AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME' environment variable." ) + endpoint_value = azure_openai_settings.get("endpoint") + client_base_url = azure_openai_settings.get("base_url") if not async_client: # Create the Azure OpenAI client directly merged_headers = dict(copy(default_headers)) if default_headers else {} @@ -332,9 +341,7 @@ class AzureOpenAIResponsesClient( # type: ignore[misc] if not api_key_secret and not ad_token_provider: raise ValueError("Please provide either api_key, credential, or a client.") - client_endpoint = azure_openai_settings.get("endpoint") - client_base_url = azure_openai_settings.get("base_url") - if not client_endpoint and not client_base_url: + if not endpoint_value and not client_base_url: raise ValueError("Please provide an endpoint or a base_url") client_args: dict[str, Any] = {"default_headers": merged_headers} @@ -346,8 +353,8 @@ class AzureOpenAIResponsesClient( # type: ignore[misc] client_args["api_key"] = api_key_secret.get_secret_value() if client_base_url: client_args["base_url"] = str(client_base_url) - if client_endpoint and not client_base_url: - client_args["azure_endpoint"] = str(client_endpoint) + if endpoint_value and not client_base_url: + client_args["azure_endpoint"] = str(endpoint_value) if responses_deployment_name: client_args["azure_deployment"] = responses_deployment_name if "websocket_base_url" in kwargs: @@ -360,16 +367,19 @@ class AzureOpenAIResponsesClient( # type: ignore[misc] self.api_version = azure_openai_settings.get("api_version") or "" self.deployment_name = responses_deployment_name - super().__init__( - async_client=async_client, - model=responses_deployment_name, - api_version=azure_openai_settings.get("api_version"), - instruction_role=instruction_role, - default_headers=default_headers, - middleware=middleware, # type: ignore[arg-type] - function_invocation_configuration=function_invocation_configuration, - **kwargs, - ) + with _prefer_single_azure_endpoint_env(endpoint=endpoint_value, base_url=client_base_url): + super().__init__( + async_client=async_client, + model=responses_deployment_name, + azure_endpoint=str(endpoint_value) if endpoint_value else None, + base_url=str(client_base_url) if client_base_url else None, + api_version=azure_openai_settings.get("api_version"), + instruction_role=instruction_role, + default_headers=default_headers, + middleware=middleware, # type: ignore[arg-type] + function_invocation_configuration=function_invocation_configuration, + **kwargs, + ) @staticmethod def _create_client_from_project( @@ -530,6 +540,8 @@ class AzureOpenAIChatClient( # type: ignore[misc] "or 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' environment variable." ) + endpoint_value = azure_openai_settings.get("endpoint") + base_url_value = azure_openai_settings.get("base_url") if not async_client: # Create the Azure OpenAI client directly merged_headers = dict(copy(default_headers)) if default_headers else {} @@ -547,8 +559,6 @@ class AzureOpenAIChatClient( # type: ignore[misc] if not api_key_secret and not ad_token_provider: raise ValueError("Please provide either api_key, credential, or a client.") - endpoint_value = azure_openai_settings.get("endpoint") - base_url_value = azure_openai_settings.get("base_url") if not endpoint_value and not base_url_value: raise ValueError("Please provide an endpoint or a base_url") @@ -573,16 +583,19 @@ class AzureOpenAIChatClient( # type: ignore[misc] self.api_version = azure_openai_settings.get("api_version") or "" self.deployment_name = chat_deployment_name - super().__init__( - async_client=async_client, - model=chat_deployment_name, - api_version=azure_openai_settings.get("api_version"), - instruction_role=instruction_role, - default_headers=default_headers, - additional_properties=additional_properties, - middleware=middleware, # type: ignore[arg-type] - function_invocation_configuration=function_invocation_configuration, - ) + with _prefer_single_azure_endpoint_env(endpoint=endpoint_value, base_url=base_url_value): + super().__init__( + async_client=async_client, + model=chat_deployment_name, + azure_endpoint=str(endpoint_value) if endpoint_value else None, + base_url=str(base_url_value) if base_url_value else None, + api_version=azure_openai_settings.get("api_version"), + instruction_role=instruction_role, + default_headers=default_headers, + additional_properties=additional_properties, + middleware=middleware, # type: ignore[arg-type] + function_invocation_configuration=function_invocation_configuration, + ) @override def _parse_text_from_openai(self, choice: Choice | ChunkChoice) -> Content | None: @@ -842,6 +855,8 @@ class AzureOpenAIEmbeddingClient( "or 'AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME' environment variable." ) + endpoint_value = azure_openai_settings.get("endpoint") + base_url_value = azure_openai_settings.get("base_url") if not async_client: # Create the Azure OpenAI client directly merged_headers = dict(copy(default_headers)) if default_headers else {} @@ -859,8 +874,6 @@ class AzureOpenAIEmbeddingClient( if not api_key_secret and not ad_token_provider: raise ValueError("Please provide either api_key, credential, or a client.") - endpoint_value = azure_openai_settings.get("endpoint") - base_url_value = azure_openai_settings.get("base_url") if not endpoint_value and not base_url_value: raise ValueError("Please provide an endpoint or a base_url") @@ -885,11 +898,15 @@ class AzureOpenAIEmbeddingClient( self.api_version = azure_openai_settings.get("api_version") or "" self.deployment_name = embedding_deployment_name - super().__init__( - async_client=async_client, - model=embedding_deployment_name, - default_headers=default_headers, - ) + with _prefer_single_azure_endpoint_env(endpoint=endpoint_value, base_url=base_url_value): + super().__init__( + async_client=async_client, + model=embedding_deployment_name, + azure_endpoint=str(endpoint_value) if endpoint_value else None, + base_url=str(base_url_value) if base_url_value else None, + api_version=azure_openai_settings.get("api_version"), + default_headers=default_headers, + ) if otel_provider_name is not None: self.OTEL_PROVIDER_NAME = otel_provider_name # type: ignore[misc] diff --git a/python/packages/azure-ai/tests/azure_openai/test_azure_chat_client.py b/python/packages/azure-ai/tests/azure_openai/test_azure_chat_client.py index 22e0a20d96..c1485d430d 100644 --- a/python/packages/azure-ai/tests/azure_openai/test_azure_chat_client.py +++ b/python/packages/azure-ai/tests/azure_openai/test_azure_chat_client.py @@ -2,6 +2,8 @@ import json import os +from functools import wraps +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import openai @@ -33,6 +35,8 @@ from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice from openai.types.chat.chat_completion_chunk import ChoiceDelta as ChunkChoiceDelta from openai.types.chat.chat_completion_message import ChatCompletionMessage +pytestmark = pytest.mark.filterwarnings("ignore:AzureOpenAIChatClient is deprecated\\..*:DeprecationWarning") + # region Service Setup skip_if_azure_integration_tests_disabled = pytest.mark.skipif( @@ -41,6 +45,32 @@ skip_if_azure_integration_tests_disabled = pytest.mark.skipif( ) +def _with_azure_openai_debug() -> Any: + def decorator(func: Any) -> Any: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return await func(*args, **kwargs) + except Exception as exc: + model = os.getenv("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME") or os.getenv( + "AZURE_OPENAI_DEPLOYMENT_NAME", "" + ) + api_version = os.getenv("AZURE_OPENAI_API_VERSION", "") + endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "") + debug_message = f"Azure OpenAI debug: endpoint={endpoint}, model={model}, api_version={api_version}" + if hasattr(exc, "add_note"): + exc.add_note(debug_message) + elif exc.args: + exc.args = (f"{exc.args[0]}\n{debug_message}", *exc.args[1:]) + else: + exc.args = (debug_message,) + raise + + return wrapper + + return decorator + + def test_init(azure_openai_unit_test_env: dict[str, str]) -> None: # Test successful initialization azure_chat_client = AzureOpenAIChatClient() @@ -820,6 +850,7 @@ def get_weather(location: str) -> str: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_client_response() -> None: """Test Azure OpenAI chat completion responses.""" azure_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) @@ -851,6 +882,7 @@ async def test_azure_openai_chat_client_response() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_client_response_tools() -> None: """Test AzureOpenAI chat completion responses.""" azure_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) @@ -873,6 +905,7 @@ async def test_azure_openai_chat_client_response_tools() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_client_streaming() -> None: """Test Azure OpenAI chat completion responses.""" azure_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) @@ -909,6 +942,7 @@ async def test_azure_openai_chat_client_streaming() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_client_streaming_tools() -> None: """Test AzureOpenAI chat completion responses.""" azure_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) @@ -937,6 +971,7 @@ async def test_azure_openai_chat_client_streaming_tools() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_client_agent_basic_run(): """Test Azure OpenAI chat client agent basic run functionality with AzureOpenAIChatClient.""" async with Agent( @@ -954,6 +989,7 @@ async def test_azure_openai_chat_client_agent_basic_run(): @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_client_agent_basic_run_streaming(): """Test Azure OpenAI chat client agent basic streaming functionality with AzureOpenAIChatClient.""" async with Agent( @@ -976,6 +1012,7 @@ async def test_azure_openai_chat_client_agent_basic_run_streaming(): @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_client_agent_session_persistence(): """Test Azure OpenAI chat client agent session persistence across runs with AzureOpenAIChatClient.""" async with Agent( @@ -1002,6 +1039,7 @@ async def test_azure_openai_chat_client_agent_session_persistence(): @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_client_agent_existing_session(): """Test Azure OpenAI chat client agent with existing session to continue conversations across agent instances.""" # First conversation - capture the session @@ -1038,6 +1076,7 @@ async def test_azure_openai_chat_client_agent_existing_session(): @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_chat_client_agent_level_tool_persistence(): """Test that agent-level tools persist across multiple runs with Azure Chat Client.""" diff --git a/python/packages/azure-ai/tests/azure_openai/test_azure_embedding_client.py b/python/packages/azure-ai/tests/azure_openai/test_azure_embedding_client.py index de78178df1..a172be577f 100644 --- a/python/packages/azure-ai/tests/azure_openai/test_azure_embedding_client.py +++ b/python/packages/azure-ai/tests/azure_openai/test_azure_embedding_client.py @@ -3,15 +3,20 @@ from __future__ import annotations import os +from functools import wraps +from typing import Any from unittest.mock import AsyncMock, MagicMock import pytest from agent_framework.azure import AzureOpenAIEmbeddingClient -from agent_framework_openai import OpenAIEmbeddingOptions +from agent_framework.openai import OpenAIEmbeddingOptions +from azure.identity.aio import AzureCliCredential from openai.types import CreateEmbeddingResponse from openai.types import Embedding as OpenAIEmbedding from openai.types.create_embedding_response import Usage +pytestmark = pytest.mark.filterwarnings("ignore:AzureOpenAIEmbeddingClient is deprecated\\..*:DeprecationWarning") + def _make_openai_response( embeddings: list[list[float]], @@ -106,20 +111,72 @@ def test_azure_otel_provider_name(azure_embedding_unit_test_env: None) -> None: skip_if_azure_openai_integration_tests_disabled = pytest.mark.skipif( - not os.getenv("AZURE_OPENAI_ENDPOINT") - or (not os.getenv("AZURE_OPENAI_API_KEY") and not os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME")), - reason="No Azure OpenAI credentials provided; skipping integration tests.", + os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.com") + or ( + os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", "") == "" + and os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "") == "" + ), + reason="No Azure OpenAI endpoint or embedding deployment provided; skipping integration tests.", ) +def _with_azure_openai_debug() -> Any: + def decorator(func: Any) -> Any: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return await func(*args, **kwargs) + except Exception as exc: + model = os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") or os.getenv( + "AZURE_OPENAI_DEPLOYMENT_NAME", "" + ) + api_version = os.getenv("AZURE_OPENAI_API_VERSION", "") + endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "") + debug_message = f"Azure OpenAI debug: endpoint={endpoint}, model={model}, api_version={api_version}" + if hasattr(exc, "add_note"): + exc.add_note(debug_message) + elif exc.args: + exc.args = (f"{exc.args[0]}\n{debug_message}", *exc.args[1:]) + else: + exc.args = (debug_message,) + raise + + return wrapper + + return decorator + + +def _get_azure_embedding_deployment_name() -> str: + return os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") or os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"] + + +def _create_azure_openai_embedding_client( + *, + api_key: str | None = None, + credential: AzureCliCredential | None = None, +) -> AzureOpenAIEmbeddingClient: + resolved_api_key = ( + api_key if api_key is not None else None if credential is not None else os.getenv("AZURE_OPENAI_API_KEY") + ) + return AzureOpenAIEmbeddingClient( + deployment_name=_get_azure_embedding_deployment_name(), + api_key=resolved_api_key, + endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], + api_version=os.getenv("AZURE_OPENAI_API_VERSION"), + credential=credential, + ) + + @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_azure_openai_get_embeddings() -> None: """End-to-end test of Azure OpenAI embedding generation.""" - client = AzureOpenAIEmbeddingClient() + async with AzureCliCredential() as credential: + client = _create_azure_openai_embedding_client(credential=credential) - result = await client.get_embeddings(["hello world"]) + result = await client.get_embeddings(["hello world"]) assert len(result) == 1 assert isinstance(result[0].vector, list) @@ -133,11 +190,13 @@ async def test_integration_azure_openai_get_embeddings() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_azure_openai_get_embeddings_multiple() -> None: """Test Azure OpenAI embedding generation for multiple inputs.""" - client = AzureOpenAIEmbeddingClient() + async with AzureCliCredential() as credential: + client = _create_azure_openai_embedding_client(credential=credential) - result = await client.get_embeddings(["hello", "world", "test"]) + result = await client.get_embeddings(["hello", "world", "test"]) assert len(result) == 3 dims = [len(e.vector) for e in result] @@ -147,12 +206,14 @@ async def test_integration_azure_openai_get_embeddings_multiple() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_azure_openai_get_embeddings_with_dimensions() -> None: """Test Azure OpenAI embedding generation with custom dimensions.""" - client = AzureOpenAIEmbeddingClient() + async with AzureCliCredential() as credential: + client = _create_azure_openai_embedding_client(credential=credential) - options: OpenAIEmbeddingOptions = {"dimensions": 256} - result = await client.get_embeddings(["hello world"], options=options) + options: OpenAIEmbeddingOptions = {"dimensions": 256} + result = await client.get_embeddings(["hello world"], options=options) assert len(result) == 1 assert len(result[0].vector) == 256 diff --git a/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client.py b/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client.py index 65e9629b96..99bd2061b7 100644 --- a/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client.py +++ b/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client.py @@ -3,9 +3,9 @@ import json import logging import os +from functools import wraps from pathlib import Path from typing import Annotated, Any -from unittest.mock import MagicMock import pytest from agent_framework import ( @@ -22,11 +22,40 @@ from azure.identity import AzureCliCredential from pydantic import BaseModel from pytest import param +pytestmark = pytest.mark.filterwarnings("ignore:AzureOpenAIResponsesClient is deprecated\\..*:DeprecationWarning") + skip_if_azure_integration_tests_disabled = pytest.mark.skipif( os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.com"), reason="No real AZURE_OPENAI_ENDPOINT provided; skipping integration tests.", ) + +def _with_azure_openai_debug() -> Any: + def decorator(func: Any) -> Any: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return await func(*args, **kwargs) + except Exception as exc: + model = os.getenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME") or os.getenv( + "AZURE_OPENAI_DEPLOYMENT_NAME", "" + ) + api_version = os.getenv("AZURE_OPENAI_API_VERSION", "") + endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "") + debug_message = f"Azure OpenAI debug: endpoint={endpoint}, model={model}, api_version={api_version}" + if hasattr(exc, "add_note"): + exc.add_note(debug_message) + elif exc.args: + exc.args = (f"{exc.args[0]}\n{debug_message}", *exc.args[1:]) + else: + exc.args = (debug_message,) + raise + + return wrapper + + return decorator + + logger = logging.getLogger(__name__) @@ -141,119 +170,6 @@ def test_init_with_empty_model_id(azure_openai_unit_test_env: dict[str, str]) -> AzureOpenAIResponsesClient() -def test_init_with_project_client(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test initialization with an existing AIProjectClient.""" - from unittest.mock import patch - - from openai import AsyncOpenAI - - # Create a mock AIProjectClient that returns a mock AsyncOpenAI client - mock_openai_client = MagicMock(spec=AsyncOpenAI) - mock_openai_client.default_headers = {} - - mock_project_client = MagicMock() - mock_project_client.get_openai_client.return_value = mock_openai_client - - with patch( - "agent_framework_azure_ai._deprecated_azure_openai.AzureOpenAIResponsesClient._create_client_from_project", - return_value=mock_openai_client, - ): - azure_responses_client = AzureOpenAIResponsesClient( - project_client=mock_project_client, - deployment_name="gpt-4o", - ) - - assert azure_responses_client.model == "gpt-4o" - assert azure_responses_client.client is mock_openai_client - assert isinstance(azure_responses_client, SupportsChatGetResponse) - - -def test_init_with_project_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test initialization with a project endpoint and credential.""" - from unittest.mock import patch - - from openai import AsyncOpenAI - - mock_openai_client = MagicMock(spec=AsyncOpenAI) - mock_openai_client.default_headers = {} - - with patch( - "agent_framework_azure_ai._deprecated_azure_openai.AzureOpenAIResponsesClient._create_client_from_project", - return_value=mock_openai_client, - ): - azure_responses_client = AzureOpenAIResponsesClient( - project_endpoint="https://test-project.services.ai.azure.com", - deployment_name="gpt-4o", - credential=AzureCliCredential(), - ) - - assert azure_responses_client.model == "gpt-4o" - assert azure_responses_client.client is mock_openai_client - assert isinstance(azure_responses_client, SupportsChatGetResponse) - - -def test_create_client_from_project_with_project_client() -> None: - """Test _create_client_from_project with an existing project client.""" - from openai import AsyncOpenAI - - mock_openai_client = MagicMock(spec=AsyncOpenAI) - mock_project_client = MagicMock() - mock_project_client.get_openai_client.return_value = mock_openai_client - - result = AzureOpenAIResponsesClient._create_client_from_project( - project_client=mock_project_client, - project_endpoint=None, - credential=None, - ) - - assert result is mock_openai_client - mock_project_client.get_openai_client.assert_called_once() - - -def test_create_client_from_project_with_endpoint() -> None: - """Test _create_client_from_project with a project endpoint.""" - from unittest.mock import patch - - from openai import AsyncOpenAI - - mock_openai_client = MagicMock(spec=AsyncOpenAI) - mock_credential = MagicMock() - - with patch("agent_framework_azure_ai._deprecated_azure_openai.AIProjectClient") as MockAIProjectClient: - mock_instance = MockAIProjectClient.return_value - mock_instance.get_openai_client.return_value = mock_openai_client - - result = AzureOpenAIResponsesClient._create_client_from_project( - project_client=None, - project_endpoint="https://test-project.services.ai.azure.com", - credential=mock_credential, - ) - - assert result is mock_openai_client - MockAIProjectClient.assert_called_once() - mock_instance.get_openai_client.assert_called_once() - - -def test_create_client_from_project_missing_endpoint() -> None: - """Test _create_client_from_project raises error when endpoint is missing.""" - with pytest.raises(ValueError, match="project endpoint is required"): - AzureOpenAIResponsesClient._create_client_from_project( - project_client=None, - project_endpoint=None, - credential=MagicMock(), - ) - - -def test_create_client_from_project_missing_credential() -> None: - """Test _create_client_from_project raises error when credential is missing.""" - with pytest.raises(ValueError, match="credential is required"): - AzureOpenAIResponsesClient._create_client_from_project( - project_client=None, - project_endpoint="https://test-project.services.ai.azure.com", - credential=None, - ) - - def test_serialize(azure_openai_unit_test_env: dict[str, str]) -> None: default_headers = {"X-Unit-Test": "test-guid"} @@ -285,8 +201,6 @@ def test_serialize(azure_openai_unit_test_env: dict[str, str]) -> None: "option_name,option_value,needs_validation", [ # Simple ChatOptions - just verify they don't fail - param("temperature", 0.7, False, id="temperature"), - param("top_p", 0.9, False, id="top_p"), param("max_tokens", 500, False, id="max_tokens"), param("seed", 123, False, id="seed"), param("user", "test-user-id", False, id="user"), @@ -299,7 +213,6 @@ def test_serialize(azure_openai_unit_test_env: dict[str, str]) -> None: # OpenAIResponsesOptions - just verify they don't fail param("safety_identifier", "user-hash-abc123", False, id="safety_identifier"), param("truncation", "auto", False, id="truncation"), - param("top_logprobs", 5, False, id="top_logprobs"), param("prompt_cache_key", "test-cache-key", False, id="prompt_cache_key"), param("max_tool_calls", 3, False, id="max_tool_calls"), # Complex options requiring output validation @@ -343,6 +256,7 @@ def test_serialize(azure_openai_unit_test_env: dict[str, str]) -> None: ), ], ) +@_with_azure_openai_debug() async def test_integration_options( option_name: str, option_value: Any, @@ -358,127 +272,84 @@ async def test_integration_options( # Need at least 2 iterations for tool_choice tests: one to get function call, one to get final response client.function_invocation_configuration["max_iterations"] = 2 - for streaming in [False, True]: - # Prepare test message + # Prepare test message + if option_name == "tools" or option_name == "tool_choice": + # Use weather-related prompt for tool tests + messages = [Message(role="user", text="What is the weather in Seattle?")] + elif option_name == "response_format": + # Use prompt that works well with structured output + messages = [ + Message(role="user", text="The weather in Seattle is sunny"), + Message(role="user", text="What is the weather in Seattle?"), + ] + else: + # Generic prompt for simple options + messages = [Message(role="user", text="Say 'Hello World' briefly.")] + + # Build options dict + options: dict[str, Any] = {option_name: option_value} + + # Add tools if testing tool_choice to avoid errors + if option_name == "tool_choice": + options["tools"] = [get_weather] + + # Test streaming mode + response = await client.get_response(messages=messages, stream=True, options=options).get_final_response() + + assert response is not None + assert isinstance(response, ChatResponse) + assert response.text is not None, f"No text in response for option '{option_name}'" + assert len(response.text) > 0, f"Empty response for option '{option_name}'" + + # Validate based on option type + if needs_validation: if option_name == "tools" or option_name == "tool_choice": - # Use weather-related prompt for tool tests - messages = [Message(role="user", text="What is the weather in Seattle?")] + # Should have called the weather function + text = response.text.lower() + assert "sunny" in text or "seattle" in text, f"Tool not invoked for {option_name}" elif option_name == "response_format": - # Use prompt that works well with structured output - messages = [ - Message(role="user", text="The weather in Seattle is sunny"), - Message(role="user", text="What is the weather in Seattle?"), - ] - else: - # Generic prompt for simple options - messages = [Message(role="user", text="Say 'Hello World' briefly.")] - - # Build options dict - options: dict[str, Any] = {option_name: option_value} - - # Add tools if testing tool_choice to avoid errors - if option_name == "tool_choice": - options["tools"] = [get_weather] - - if streaming: - # Test streaming mode - response_stream = client.get_response( - messages=messages, - stream=True, - options=options, - ) - - response = await response_stream.get_final_response() - else: - # Test non-streaming mode - response = await client.get_response( - messages=messages, - options=options, - ) - - assert response is not None - assert isinstance(response, ChatResponse) - assert response.text is not None, f"No text in response for option '{option_name}'" - assert len(response.text) > 0, f"Empty response for option '{option_name}'" - - # Validate based on option type - if needs_validation: - if option_name == "tools" or option_name == "tool_choice": - # Should have called the weather function - text = response.text.lower() - assert "sunny" in text or "seattle" in text, f"Tool not invoked for {option_name}" - elif option_name == "response_format": - if option_value == OutputStruct: - # Should have structured output - assert response.value is not None, "No structured output" - assert isinstance(response.value, OutputStruct) - assert "seattle" in response.value.location.lower() - else: - # Runtime JSON schema - assert response.value is None, "No structured output, can't parse any json." - response_value = json.loads(response.text) - assert isinstance(response_value, dict) - assert "location" in response_value - assert "seattle" in response_value["location"].lower() + if option_value == OutputStruct: + # Should have structured output + assert response.value is not None, "No structured output" + assert isinstance(response.value, OutputStruct) + assert "seattle" in response.value.location.lower() + else: + # Runtime JSON schema + assert response.value is None, "No structured output, can't parse any json." + response_value = json.loads(response.text) + assert isinstance(response_value, dict) + assert "location" in response_value + assert "seattle" in response_value["location"].lower() @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_web_search() -> None: client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) + response = await client.get_response( + messages=[ + Message( + role="user", + text="What is the current weather? Do not ask for my current location.", + ) + ], + options={ + "tools": [ + AzureOpenAIResponsesClient.get_web_search_tool(user_location={"country": "US", "city": "Seattle"}) + ] + }, + stream=True, + ).get_final_response() - for streaming in [False, True]: - content = { - "messages": [ - Message( - role="user", - text="Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.", - ) - ], - "options": { - "tool_choice": "auto", - "tools": [AzureOpenAIResponsesClient.get_web_search_tool()], - }, - "stream": streaming, - } - if streaming: - response = await client.get_response(**content).get_final_response() - else: - response = await client.get_response(**content) - - assert response is not None - assert isinstance(response, ChatResponse) - assert "Rumi" in response.text - assert "Mira" in response.text - assert "Zoey" in response.text - - # Test that the client will use the web search tool with location - content = { - "messages": [ - Message( - role="user", - text="What is the current weather? Do not ask for my current location.", - ) - ], - "options": { - "tool_choice": "auto", - "tools": [ - AzureOpenAIResponsesClient.get_web_search_tool(user_location={"country": "US", "city": "Seattle"}) - ], - }, - "stream": streaming, - } - if streaming: - response = await client.get_response(**content).get_final_response() - else: - response = await client.get_response(**content) - assert response.text is not None + assert response.text is not None @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_client_file_search() -> None: """Test Azure responses client with file search tool.""" azure_responses_client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) @@ -509,6 +380,7 @@ async def test_integration_client_file_search() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_client_file_search_streaming() -> None: """Test Azure responses client with file search tool and streaming.""" azure_responses_client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) @@ -541,6 +413,7 @@ async def test_integration_client_file_search_streaming() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_client_agent_hosted_mcp_tool() -> None: """Integration test for MCP tool with Azure Response Agent using Microsoft Learn MCP.""" client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) @@ -566,6 +439,7 @@ async def test_integration_client_agent_hosted_mcp_tool() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_client_agent_hosted_code_interpreter_tool(): """Test Azure Responses Client agent with code interpreter tool.""" client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) @@ -591,6 +465,7 @@ async def test_integration_client_agent_hosted_code_interpreter_tool(): @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_client_agent_existing_session(): """Test Azure Responses Client agent with existing session to continue conversations across agent instances.""" # First conversation - capture the session @@ -627,6 +502,7 @@ async def test_integration_client_agent_existing_session(): @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_responses_client_tool_rich_content_image() -> None: """Test that Azure OpenAI Responses client can handle tool results containing images.""" image_path = Path(__file__).parent.parent / "assets" / "sample_image.jpg" @@ -660,70 +536,3 @@ async def test_azure_openai_responses_client_tool_rich_content_image() -> None: assert len(response.text) > 0 # sample_image.jpg contains a photo of a house; the model should mention it. assert "house" in response.text.lower(), f"Model did not describe the house image. Response: {response.text}" - - -# region Integration with Foundry V2 - - -skip_if_azure_ai_integration_tests_disabled = pytest.mark.skipif( - os.getenv("AZURE_AI_PROJECT_ENDPOINT", "") in ("", "https://test-project.cognitiveservices.azure.com/") - or os.getenv("AZURE_AI_MODEL", "") == "", - reason="No real AZURE_AI_PROJECT_ENDPOINT or AZURE_AI_MODEL provided; skipping integration tests.", -) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_ai_integration_tests_disabled -async def test_integration_function_call_roundtrip_preserves_fidelity(): - """Test that function calls roundtrip correctly with full fidelity preserved. - - This verifies the changes where: - 1. raw_representation is preserved when parsing function calls - 2. fc_id and status are included in additional_properties - 3. When re-sending messages, the full object fidelity is preserved - """ - call_count = 0 - - @tool(name="get_weather", approval_mode="never_require") - async def get_weather_tool(location: str) -> str: - """Get weather for a location.""" - nonlocal call_count - call_count += 1 - return f"Weather in {location} is sunny, 72F" - - client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL"], - credential=AzureCliCredential(), - ) - - async with Agent( - client=client, - name="WeatherAgent", - instructions="You help check weather. Use get_weather when asked about weather.", - tools=[get_weather_tool], - default_options={"store": False}, # Store messages locally to test fidelity across messages - ) as agent: - session = agent.create_session() - - # First request - should invoke the tool - response1 = await agent.run("What is the weather in Seattle?", session=session) - - assert response1 is not None - assert response1.text is not None - assert call_count >= 1 - - # Verify the response contains expected content - response_text = response1.text.lower() - assert "seattle" in response_text or "sunny" in response_text or "72" in response_text - - # Second request - should work correctly with the preserved conversation - response2 = await agent.run("And how about in Portland?", session=session) - - assert response2 is not None - assert response2.text is not None - assert call_count >= 2 - - -# endregion diff --git a/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py b/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py new file mode 100644 index 0000000000..bbf10e7b88 --- /dev/null +++ b/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py @@ -0,0 +1,131 @@ +# Copyright (c) Microsoft. All rights reserved. + +import warnings +from unittest.mock import MagicMock + +import pytest +from agent_framework import SupportsChatGetResponse + +warnings.filterwarnings( + "ignore", + message=r"RawAzureAIClient is deprecated\..*", + category=DeprecationWarning, +) + +from agent_framework.azure import AzureOpenAIResponsesClient # noqa: E402 +from azure.identity import AzureCliCredential # noqa: E402 + +pytestmark = pytest.mark.filterwarnings("ignore:AzureOpenAIResponsesClient is deprecated\\..*:DeprecationWarning") + + +def test_init_with_project_client(azure_openai_unit_test_env: dict[str, str]) -> None: + """Test initialization with an existing AIProjectClient.""" + from unittest.mock import patch + + from openai import AsyncOpenAI + + # Create a mock AIProjectClient that returns a mock AsyncOpenAI client + mock_openai_client = MagicMock(spec=AsyncOpenAI) + mock_openai_client.default_headers = {} + + mock_project_client = MagicMock() + mock_project_client.get_openai_client.return_value = mock_openai_client + + with patch( + "agent_framework_azure_ai._deprecated_azure_openai.AzureOpenAIResponsesClient._create_client_from_project", + return_value=mock_openai_client, + ): + azure_responses_client = AzureOpenAIResponsesClient( + project_client=mock_project_client, + deployment_name="gpt-4o", + ) + + assert azure_responses_client.model == "gpt-4o" + assert azure_responses_client.client is mock_openai_client + assert isinstance(azure_responses_client, SupportsChatGetResponse) + + +def test_init_with_project_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: + """Test initialization with a project endpoint and credential.""" + from unittest.mock import patch + + from openai import AsyncOpenAI + + mock_openai_client = MagicMock(spec=AsyncOpenAI) + mock_openai_client.default_headers = {} + + with patch( + "agent_framework_azure_ai._deprecated_azure_openai.AzureOpenAIResponsesClient._create_client_from_project", + return_value=mock_openai_client, + ): + azure_responses_client = AzureOpenAIResponsesClient( + project_endpoint="https://test-project.services.ai.azure.com", + deployment_name="gpt-4o", + credential=AzureCliCredential(), + ) + + assert azure_responses_client.model == "gpt-4o" + assert azure_responses_client.client is mock_openai_client + assert isinstance(azure_responses_client, SupportsChatGetResponse) + + +def test_create_client_from_project_with_project_client() -> None: + """Test _create_client_from_project with an existing project client.""" + from openai import AsyncOpenAI + + mock_openai_client = MagicMock(spec=AsyncOpenAI) + mock_project_client = MagicMock() + mock_project_client.get_openai_client.return_value = mock_openai_client + + result = AzureOpenAIResponsesClient._create_client_from_project( + project_client=mock_project_client, + project_endpoint=None, + credential=None, + ) + + assert result is mock_openai_client + mock_project_client.get_openai_client.assert_called_once() + + +def test_create_client_from_project_with_endpoint() -> None: + """Test _create_client_from_project with a project endpoint.""" + from unittest.mock import patch + + from openai import AsyncOpenAI + + mock_openai_client = MagicMock(spec=AsyncOpenAI) + mock_credential = MagicMock() + + with patch("agent_framework_azure_ai._deprecated_azure_openai.AIProjectClient") as MockAIProjectClient: + mock_instance = MockAIProjectClient.return_value + mock_instance.get_openai_client.return_value = mock_openai_client + + result = AzureOpenAIResponsesClient._create_client_from_project( + project_client=None, + project_endpoint="https://test-project.services.ai.azure.com", + credential=mock_credential, + ) + + assert result is mock_openai_client + MockAIProjectClient.assert_called_once() + mock_instance.get_openai_client.assert_called_once() + + +def test_create_client_from_project_missing_endpoint() -> None: + """Test _create_client_from_project raises error when endpoint is missing.""" + with pytest.raises(ValueError, match="project endpoint is required"): + AzureOpenAIResponsesClient._create_client_from_project( + project_client=None, + project_endpoint=None, + credential=MagicMock(), + ) + + +def test_create_client_from_project_missing_credential() -> None: + """Test _create_client_from_project raises error when credential is missing.""" + with pytest.raises(ValueError, match="credential is required"): + AzureOpenAIResponsesClient._create_client_from_project( + project_client=None, + project_endpoint="https://test-project.services.ai.azure.com", + credential=None, + ) diff --git a/python/packages/azure-ai/tests/test_azure_ai_client.py b/python/packages/azure-ai/tests/test_azure_ai_client.py index f0d8cbc430..f3e459d0a4 100644 --- a/python/packages/azure-ai/tests/test_azure_ai_client.py +++ b/python/packages/azure-ai/tests/test_azure_ai_client.py @@ -3,6 +3,7 @@ import json import os import sys +import warnings from collections.abc import AsyncGenerator, AsyncIterator from contextlib import asynccontextmanager from typing import Annotated, Any @@ -41,8 +42,21 @@ from openai.types.responses.response import Response as OpenAIResponse from pydantic import BaseModel, ConfigDict, Field from pytest import fixture -from agent_framework_azure_ai import AzureAIClient, AzureAISettings -from agent_framework_azure_ai._shared import from_azure_ai_tools +from agent_framework_azure_ai import AzureAIClient, AzureAISettings # noqa: E402 +from agent_framework_azure_ai._shared import from_azure_ai_tools # noqa: E402 + +warnings.filterwarnings( + "ignore", + message=r"RawAzureAIClient is deprecated\..*", + category=DeprecationWarning, +) +warnings.filterwarnings( + "ignore", + message=r"AzureAIClient is deprecated\..*", + category=DeprecationWarning, +) + +pytestmark = pytest.mark.filterwarnings("ignore:AzureAIClient is deprecated\\..*:DeprecationWarning") @pytest.fixture diff --git a/python/packages/core/README.md b/python/packages/core/README.md index 9d88cc4556..36f0f6e02c 100644 --- a/python/packages/core/README.md +++ b/python/packages/core/README.md @@ -29,8 +29,8 @@ Set as environment variables, or create a .env file at your project root: ```bash OPENAI_API_KEY=sk-... -OPENAI_CHAT_MODEL_ID=... -OPENAI_RESPONSES_MODEL_ID=... +OPENAI_CHAT_MODEL=... +OPENAI_RESPONSES_MODEL=... ... AZURE_OPENAI_API_KEY=... AZURE_OPENAI_ENDPOINT=... diff --git a/python/packages/devui/agent_framework_devui/ui/assets/index.js b/python/packages/devui/agent_framework_devui/ui/assets/index.js index 6387ada58a..a71e62397f 100644 --- a/python/packages/devui/agent_framework_devui/ui/assets/index.js +++ b/python/packages/devui/agent_framework_devui/ui/assets/index.js @@ -481,7 +481,7 @@ services: environment: # OpenAI - OPENAI_API_KEY=\${OPENAI_API_KEY} - - OPENAI_CHAT_MODEL_ID=\${OPENAI_CHAT_MODEL_ID:-gpt-4o-mini} + - OPENAI_CHAT_MODEL=\${OPENAI_CHAT_MODEL:-gpt-4o-mini} # Or Azure OpenAI - AZURE_OPENAI_API_KEY=\${AZURE_OPENAI_API_KEY} - AZURE_OPENAI_ENDPOINT=\${AZURE_OPENAI_ENDPOINT} @@ -514,7 +514,10 @@ az acr build --registry myregistry \\ --target-port 8080 \\ --ingress 'external' \\ --registry-server myregistry.azurecr.io \\ - --env-vars OPENAI_API_KEY=secretref:openai-key OPENAI_CHAT_MODEL_ID=gpt-4o-mini`})]}),o.jsxs("div",{className:"border-l-2 border-primary pl-3",children:[o.jsxs("div",{className:"flex items-center gap-2 mb-1",children:[o.jsx("div",{className:"w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold",children:"5"}),o.jsx("h5",{className:"font-medium text-sm",children:"Get Application URL"})]}),o.jsx("pre",{className:"bg-muted p-2 rounded text-xs overflow-x-auto border mt-2",children:`az containerapp show --name ${r.toLowerCase()}-app \\ + --env-vars OPENAI_API_KEY=secretref:openai-key OPENAI_CHAT_MODEL=gpt-4o-mini`})] + }), o.jsxs("div", { + className: "border-l-2 border-primary pl-3", children: [o.jsxs("div", { className: "flex items-center gap-2 mb-1", children: [o.jsx("div", { className: "w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold", children: "5" }), o.jsx("h5", { className: "font-medium text-sm", children: "Get Application URL" })] }), o.jsx("pre", { + className: "bg-muted p-2 rounded text-xs overflow-x-auto border mt-2", children: `az containerapp show --name ${r.toLowerCase()}-app \\ --resource-group myResourceGroup \\ --query properties.configuration.ingress.fqdn`})]})]})]}),o.jsxs("div",{className:"bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded-md p-3",children:[o.jsx("h4",{className:"text-sm font-semibold mb-2",children:"Learn More"}),o.jsx("p",{className:"text-xs text-muted-foreground mb-3",children:"Explore Azure Container Apps documentation for advanced features like scaling, monitoring, and CI/CD integration."}),o.jsx(Le,{size:"sm",variant:"outline",className:"w-full",asChild:!0,children:o.jsxs("a",{href:"https://learn.microsoft.com/azure/container-apps/",target:"_blank",rel:"noopener noreferrer",children:[o.jsx(Hu,{className:"h-3 w-3 mr-1"}),"View Azure Container Apps Documentation"]})})]})]})]})]})})})]})})}function tD({className:e,...n}){return o.jsx("div",{"data-slot":"card",className:We("bg-card text-card-foreground flex flex-col gap-6 rounded border py-6 shadow-sm",e),...n})}function nD({className:e,...n}){return o.jsx("div",{"data-slot":"card-header",className:We("@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",e),...n})}function N2({className:e,...n}){return o.jsx("div",{"data-slot":"card-title",className:We("leading-none font-semibold",e),...n})}function sD({className:e,...n}){return o.jsx("div",{"data-slot":"card-description",className:We("text-muted-foreground text-sm",e),...n})}function rD({className:e,...n}){return o.jsx("div",{"data-slot":"card-content",className:We("px-6",e),...n})}function oD({className:e,...n}){return o.jsx("div",{"data-slot":"card-footer",className:We("flex items-center px-6 [.border-t]:pt-6",e),...n})}const Cr=[{id:"foundry-weather-agent",name:"Azure AI Weather Agent",description:"Weather agent using Azure AI Agent (Foundry) with Azure CLI authentication",type:"agent",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/foundry_agent/agent.py",tags:["azure-ai","foundry","tools"],author:"Microsoft",difficulty:"beginner",features:["Azure AI Agent integration","Azure CLI authentication","Mock weather tools"],requiredEnvVars:[{name:"AZURE_AI_PROJECT_ENDPOINT",description:"Azure AI Foundry project endpoint URL",required:!0,example:"https://your-project.api.azureml.ms"},{name:"FOUNDRY_MODEL_DEPLOYMENT_NAME",description:"Name of the deployed model in Azure AI Foundry",required:!0,example:"gpt-4o"}]},{id:"weather-agent-azure",name:"Azure OpenAI Weather Agent",description:"Weather agent using Azure OpenAI with API key authentication",type:"agent",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/weather_agent_azure/agent.py",tags:["azure","openai","tools"],author:"Microsoft",difficulty:"beginner",features:["Azure OpenAI integration","API key authentication","Function calling","Mock weather tools"],requiredEnvVars:[{name:"AZURE_OPENAI_API_KEY",description:"Azure OpenAI API key",required:!0},{name:"AZURE_OPENAI_CHAT_DEPLOYMENT_NAME",description:"Name of the deployed model in Azure OpenAI",required:!0,example:"gpt-4o"},{name:"AZURE_OPENAI_ENDPOINT",description:"Azure OpenAI endpoint URL",required:!0,example:"https://your-resource.openai.azure.com"}]},{id:"spam-workflow",name:"Spam Detection Workflow",description:"5-step workflow demonstrating email spam detection with branching logic",type:"workflow",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/spam_workflow/workflow.py",tags:["workflow","branching","multi-step"],author:"Microsoft",difficulty:"beginner",features:["Sequential execution","Conditional branching","Mock spam detection"]},{id:"fanout-workflow",name:"Complex Fan-In/Fan-Out Workflow",description:"Advanced data processing workflow with parallel validation, transformation, and quality assurance stages",type:"workflow",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/fanout_workflow/workflow.py",tags:["workflow","fan-out","fan-in","parallel"],author:"Microsoft",difficulty:"advanced",features:["Fan-out pattern","Parallel execution","Complex state management","Multi-stage processing"]}];Cr.filter(e=>e.type==="agent"),Cr.filter(e=>e.type==="workflow"),Cr.filter(e=>e.difficulty==="beginner"),Cr.filter(e=>e.difficulty==="intermediate"),Cr.filter(e=>e.difficulty==="advanced");const aD=e=>{switch(e){case"beginner":return"bg-green-100 text-green-700 border-green-200";case"intermediate":return"bg-yellow-100 text-yellow-700 border-yellow-200";case"advanced":return"bg-red-100 text-red-700 border-red-200";default:return"bg-gray-100 text-gray-700 border-gray-200"}},j2=w.forwardRef(({className:e,...n},r)=>o.jsx("div",{ref:r,role:"alert",className:We("relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",e),...n}));j2.displayName="Alert";const S2=w.forwardRef(({className:e,...n},r)=>o.jsx("h5",{ref:r,className:We("mb-1 font-medium leading-none tracking-tight",e),...n}));S2.displayName="AlertTitle";const _2=w.forwardRef(({className:e,...n},r)=>o.jsx("div",{ref:r,className:We("text-sm [&_p]:leading-relaxed",e),...n}));_2.displayName="AlertDescription";function E2({children:e,copyable:n=!1}){const[r,a]=w.useState(!1),l=()=>{navigator.clipboard.writeText(e),a(!0),setTimeout(()=>a(!1),2e3)};return o.jsxs("div",{className:"relative",children:[o.jsx("pre",{className:"bg-muted p-3 rounded-md text-sm overflow-x-auto font-mono",children:o.jsx("code",{children:e})}),n&&o.jsx(Le,{variant:"ghost",size:"sm",className:"absolute top-2 right-2 h-6 w-6 p-0",onClick:l,children:r?o.jsx(jo,{className:"h-3 w-3"}):o.jsx(uo,{className:"h-3 w-3"})})]})}function iu({number:e,title:n,description:r,code:a,action:l,copyable:c=!1}){return o.jsxs("div",{className:"flex gap-4",children:[o.jsx("div",{className:"flex-shrink-0",children:o.jsx("div",{className:"flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground font-semibold",children:e})}),o.jsxs("div",{className:"flex-1 space-y-2",children:[o.jsx("h4",{className:"font-semibold",children:n}),r&&o.jsx("p",{className:"text-sm text-muted-foreground",children:r}),a&&o.jsx(E2,{copyable:c,children:a}),l&&o.jsx("div",{children:l})]})]})}function iD({sample:e,open:n,onOpenChange:r}){const a=e.requiredEnvVars&&e.requiredEnvVars.length>0,l=a?0:-1;return o.jsx(Ir,{open:n,onOpenChange:r,children:o.jsxs(Lr,{className:"max-w-3xl",children:[o.jsxs($r,{className:"px-6 pt-6 pb-2",children:[o.jsxs(Pr,{children:["Setup: ",e.name]}),o.jsxs(OR,{children:["Follow these steps to run this sample ",e.type," locally"]})]}),o.jsx("div",{className:"px-6 pb-6",children:o.jsx(Wn,{className:"h-[500px]",children:o.jsxs("div",{className:"space-y-6 pr-4",children:[o.jsx(iu,{number:1,title:"Download the sample file",action:o.jsx(Le,{asChild:!0,size:"sm",children:o.jsxs("a",{href:e.url,download:`${e.id}.py`,target:"_blank",rel:"noopener noreferrer",children:[o.jsx(Pu,{className:"h-4 w-4 mr-2"}),"Download ",e.id,".py"]})})}),o.jsx(iu,{number:2,title:"Create a project folder",description:"Create a dedicated folder for this sample and move the downloaded file there:",code:`mkdir -p ~/my-agents/${e.id} mv ~/Downloads/${e.id}.py ~/my-agents/${e.id}/`,copyable:!0}),a&&o.jsx(iu,{number:3,title:"Set up environment variables",description:"Create a .env file in the project folder with these required variables:",code:e.requiredEnvVars.map(c=>`${c.name}=${c.example||"your-value-here"} diff --git a/python/packages/devui/dev.md b/python/packages/devui/dev.md index 5a4166112d..0566e75429 100644 --- a/python/packages/devui/dev.md +++ b/python/packages/devui/dev.md @@ -33,7 +33,7 @@ Then edit `.env` and add your API keys: ```bash # For OpenAI (minimum required) OPENAI_API_KEY="your-api-key-here" -OPENAI_CHAT_MODEL_ID="gpt-4o-mini" +OPENAI_CHAT_MODEL="gpt-4o-mini" # Or for Azure OpenAI AZURE_OPENAI_ENDPOINT="your-endpoint" diff --git a/python/packages/devui/frontend/src/components/layout/deployment-modal.tsx b/python/packages/devui/frontend/src/components/layout/deployment-modal.tsx index 5a90a5350a..dd5ae81180 100644 --- a/python/packages/devui/frontend/src/components/layout/deployment-modal.tsx +++ b/python/packages/devui/frontend/src/components/layout/deployment-modal.tsx @@ -243,7 +243,7 @@ services: environment: # OpenAI - OPENAI_API_KEY=\${OPENAI_API_KEY} - - OPENAI_CHAT_MODEL_ID=\${OPENAI_CHAT_MODEL_ID:-gpt-4o-mini} + - OPENAI_CHAT_MODEL=\${OPENAI_CHAT_MODEL:-gpt-4o-mini} # Or Azure OpenAI - AZURE_OPENAI_API_KEY=\${AZURE_OPENAI_API_KEY} - AZURE_OPENAI_ENDPOINT=\${AZURE_OPENAI_ENDPOINT} @@ -802,7 +802,7 @@ az acr build --registry myregistry \\ --target-port 8080 \\ --ingress 'external' \\ --registry-server myregistry.azurecr.io \\ - --env-vars OPENAI_API_KEY=secretref:openai-key OPENAI_CHAT_MODEL_ID=gpt-4o-mini`} + --env-vars OPENAI_API_KEY=secretref:openai-key OPENAI_CHAT_MODEL=gpt-4o-mini`} diff --git a/python/packages/foundry/agent_framework_foundry/__init__.py b/python/packages/foundry/agent_framework_foundry/__init__.py index 3f7a5d5095..50c500ad4e 100644 --- a/python/packages/foundry/agent_framework_foundry/__init__.py +++ b/python/packages/foundry/agent_framework_foundry/__init__.py @@ -2,10 +2,9 @@ import importlib.metadata -from ._foundry_agent import FoundryAgent, RawFoundryAgent -from ._foundry_agent_client import RawFoundryAgentChatClient -from ._foundry_chat_client import FoundryChatClient, FoundryChatOptions, RawFoundryChatClient -from ._foundry_memory_provider import FoundryMemoryProvider +from ._agent import FoundryAgent, RawFoundryAgent, RawFoundryAgentChatClient +from ._chat_client import FoundryChatClient, FoundryChatOptions, RawFoundryChatClient +from ._memory_provider import FoundryMemoryProvider try: __version__ = importlib.metadata.version(__name__) diff --git a/python/packages/foundry/agent_framework_foundry/_foundry_agent_client.py b/python/packages/foundry/agent_framework_foundry/_agent.py similarity index 57% rename from python/packages/foundry/agent_framework_foundry/_foundry_agent_client.py rename to python/packages/foundry/agent_framework_foundry/_agent.py index 0976d5572a..67c6f6070d 100644 --- a/python/packages/foundry/agent_framework_foundry/_foundry_agent_client.py +++ b/python/packages/foundry/agent_framework_foundry/_agent.py @@ -1,30 +1,37 @@ # Copyright (c) Microsoft. All rights reserved. -"""Microsoft Foundry Agent client for connecting to pre-configured agents in Foundry. +"""Microsoft Foundry Agent for connecting to pre-configured agents in Foundry. -This module provides ``RawFoundryAgentClient`` and ``FoundryAgentClient`` for -communicating with PromptAgents and HostedAgents via the Responses API. +This module provides ``RawFoundryAgent`` and ``FoundryAgent`` — Agent subclasses +that connect to existing PromptAgents or HostedAgents in Foundry. Use +``FoundryAgent`` for the recommended experience with full middleware and telemetry. """ from __future__ import annotations import logging import sys -from collections.abc import Callable, Mapping, MutableMapping, Sequence +from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequence from typing import TYPE_CHECKING, Any, ClassVar, Generic, cast -from agent_framework._middleware import ChatMiddlewareLayer -from agent_framework._settings import load_settings -from agent_framework._telemetry import AGENT_FRAMEWORK_USER_AGENT -from agent_framework._tools import FunctionInvocationConfiguration, FunctionInvocationLayer, FunctionTool -from agent_framework._types import Message -from agent_framework.observability import ChatTelemetryLayer +from agent_framework import ( + AGENT_FRAMEWORK_USER_AGENT, + AgentMiddlewareLayer, + BaseContextProvider, + ChatAndFunctionMiddlewareTypes, + ChatMiddlewareLayer, + FunctionInvocationConfiguration, + FunctionInvocationLayer, + FunctionTool, + Message, + RawAgent, + load_settings, +) +from agent_framework.observability import AgentTelemetryLayer, ChatTelemetryLayer from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient from azure.ai.projects.aio import AIProjectClient - -from ._entra_id_authentication import AzureCredentialTypes - -logger: logging.Logger = logging.getLogger(__name__) +from azure.core.credentials import TokenCredential +from azure.core.credentials_async import AsyncTokenCredential if sys.version_info >= (3, 13): from typing import TypeVar # type: ignore # pragma: no cover @@ -33,22 +40,25 @@ else: if sys.version_info >= (3, 12): from typing import override # type: ignore # pragma: no cover else: - from typing_extensions import override # type: ignore # pragma: no cover + from typing_extensions import override # type: ignore[import] # pragma: no cover if sys.version_info >= (3, 11): from typing import TypedDict # type: ignore # pragma: no cover else: from typing_extensions import TypedDict # type: ignore # pragma: no cover if TYPE_CHECKING: - from agent_framework import Agent, BaseContextProvider - from agent_framework._middleware import ( - ChatMiddleware, - ChatMiddlewareCallable, - FunctionMiddleware, - FunctionMiddlewareCallable, + from agent_framework import ( + Agent, + BaseContextProvider, + ChatAndFunctionMiddlewareTypes, MiddlewareTypes, + ToolTypes, ) - from agent_framework._tools import ToolTypes + +logger: logging.Logger = logging.getLogger("agent_framework.foundry") + +AzureTokenProvider = Callable[[], str | Awaitable[str]] +AzureCredentialTypes = TokenCredential | AsyncTokenCredential class FoundryAgentSettings(TypedDict, total=False): @@ -203,8 +213,6 @@ class RawFoundryAgentChatClient( # type: ignore[misc] **kwargs: Any, ) -> Agent[FoundryAgentOptionsT]: """Create a FoundryAgent that reuses this client's Foundry configuration.""" - from ._foundry_agent import FoundryAgent - function_tools = cast( FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None, tools, @@ -359,9 +367,7 @@ class _FoundryAgentChatClient( # type: ignore[misc] allow_preview: bool | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - middleware: ( - Sequence[ChatMiddleware | ChatMiddlewareCallable | FunctionMiddleware | FunctionMiddlewareCallable] | None - ) = None, + middleware: (Sequence[ChatAndFunctionMiddlewareTypes] | None) = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, **kwargs: Any, ) -> None: @@ -393,3 +399,236 @@ class _FoundryAgentChatClient( # type: ignore[misc] function_invocation_configuration=function_invocation_configuration, **kwargs, ) + + +class RawFoundryAgent( # type: ignore[misc] + RawAgent[FoundryAgentOptionsT], +): + """Raw Microsoft Foundry Agent without agent-level middleware or telemetry. + + Connects to an existing PromptAgent or HostedAgent in Foundry. + For full middleware and telemetry support, use :class:`FoundryAgent`. + + Examples: + .. code-block:: python + + from agent_framework.foundry import RawFoundryAgent + from azure.identity import AzureCliCredential + + agent = RawFoundryAgent( + project_endpoint="https://your-project.services.ai.azure.com", + agent_name="my-prompt-agent", + agent_version="1.0", + credential=AzureCliCredential(), + ) + result = await agent.run("Hello!") + """ + + def __init__( + self, + *, + project_endpoint: str | None = None, + agent_name: str | None = None, + agent_version: str | None = None, + credential: AzureCredentialTypes | None = None, + project_client: AIProjectClient | None = None, + allow_preview: bool | None = None, + tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None, + context_providers: Sequence[BaseContextProvider] | None = None, + client_type: type[RawFoundryAgentChatClient] | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize a Foundry Agent. + + Keyword Args: + project_endpoint: The Foundry project endpoint URL. + Can also be set via environment variable FOUNDRY_PROJECT_ENDPOINT. + agent_name: The name of the Foundry agent to connect to. + Can also be set via environment variable FOUNDRY_AGENT_NAME. + agent_version: The version of the agent (required for PromptAgents, optional for HostedAgents). + Can also be set via environment variable FOUNDRY_AGENT_VERSION. + credential: Azure credential for authentication. + project_client: An existing AIProjectClient to use. + allow_preview: Enables preview opt-in on internally-created AIProjectClient. + tools: Function tools to provide to the agent. Only ``FunctionTool`` objects are accepted. + context_providers: Optional context providers for injecting dynamic context. + client_type: Custom client class to use (must be a subclass of ``RawFoundryAgentChatClient``). + Defaults to ``_FoundryAgentChatClient`` (full client middleware). + env_file_path: Path to .env file for settings. + env_file_encoding: Encoding for .env file. + kwargs: Additional keyword arguments passed to the Agent base class. + """ + # Create the client + actual_client_type = client_type or _FoundryAgentChatClient + if not issubclass(actual_client_type, RawFoundryAgentChatClient): + raise TypeError( + f"client_type must be a subclass of RawFoundryAgentChatClient, got {actual_client_type.__name__}" + ) + + client = actual_client_type( + project_endpoint=project_endpoint, + agent_name=agent_name, + agent_version=agent_version, + credential=credential, + project_client=project_client, + allow_preview=allow_preview, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + + super().__init__( + client=client, # type: ignore[arg-type] + tools=tools, # type: ignore[arg-type] + context_providers=context_providers, + **kwargs, + ) + + async def configure_azure_monitor( + self, + enable_sensitive_data: bool = False, + **kwargs: Any, + ) -> None: + """Setup observability with Azure Monitor (Microsoft Foundry integration). + + This method configures Azure Monitor for telemetry collection using the + connection string from the Foundry project client (accessed via the internal client). + + Args: + enable_sensitive_data: Enable sensitive data logging (prompts, responses). + Should only be enabled in development/test environments. Default is False. + **kwargs: Additional arguments passed to configure_azure_monitor(). + + Raises: + ImportError: If azure-monitor-opentelemetry-exporter is not installed. + """ + from azure.core.exceptions import ResourceNotFoundError + + client = self.client + if not isinstance(client, RawFoundryAgentChatClient): + raise TypeError("configure_azure_monitor requires a RawFoundryAgentChatClient-based client.") + + try: + conn_string = await client.project_client.telemetry.get_application_insights_connection_string() + except ResourceNotFoundError: + logger.warning( + "No Application Insights connection string found for the Foundry project. " + "Please ensure Application Insights is configured in your project, " + "or call configure_otel_providers() manually with custom exporters." + ) + return + + try: + from azure.monitor.opentelemetry import configure_azure_monitor # type: ignore[import] + except ImportError as exc: + raise ImportError( + "azure-monitor-opentelemetry is required for Azure Monitor integration. " + "Install it with: pip install azure-monitor-opentelemetry" + ) from exc + + from agent_framework.observability import create_metric_views, create_resource, enable_instrumentation + + if "resource" not in kwargs: + kwargs["resource"] = create_resource() + + configure_azure_monitor( + connection_string=conn_string, + views=create_metric_views(), + **kwargs, + ) + + enable_instrumentation(enable_sensitive_data=enable_sensitive_data) + + +class FoundryAgent( # type: ignore[misc] + AgentMiddlewareLayer, + AgentTelemetryLayer, + RawFoundryAgent[FoundryAgentOptionsT], +): + """Microsoft Foundry Agent with full middleware and telemetry support. + + Connects to an existing PromptAgent or HostedAgent in Foundry. + This is the recommended class for production use. + + Examples: + .. code-block:: python + + from agent_framework.foundry import FoundryAgent + from azure.identity import AzureCliCredential + + # Connect to a PromptAgent + agent = FoundryAgent( + project_endpoint="https://your-project.services.ai.azure.com", + agent_name="my-prompt-agent", + agent_version="1.0", + credential=AzureCliCredential(), + tools=[my_function_tool], + ) + result = await agent.run("Hello!") + + # Connect to a HostedAgent (no version needed) + agent = FoundryAgent( + project_endpoint="https://your-project.services.ai.azure.com", + agent_name="my-hosted-agent", + credential=AzureCliCredential(), + ) + + # Custom client (e.g., raw client without client middleware) + agent = FoundryAgent( + project_endpoint="https://your-project.services.ai.azure.com", + agent_name="my-agent", + credential=AzureCliCredential(), + client_type=RawFoundryAgentChatClient, + ) + """ + + def __init__( + self, + *, + project_endpoint: str | None = None, + agent_name: str | None = None, + agent_version: str | None = None, + credential: AzureCredentialTypes | None = None, + project_client: AIProjectClient | None = None, + allow_preview: bool | None = None, + tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None, + context_providers: Sequence[BaseContextProvider] | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, + client_type: type[RawFoundryAgentChatClient] | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize a Foundry Agent with full middleware and telemetry. + + Keyword Args: + project_endpoint: The Foundry project endpoint URL. + agent_name: The name of the Foundry agent to connect to. + agent_version: The version of the agent (for PromptAgents). + credential: Azure credential for authentication. + project_client: An existing AIProjectClient to use. + allow_preview: Enables preview opt-in on internally-created AIProjectClient. + tools: Function tools to provide to the agent. Only ``FunctionTool`` objects are accepted. + context_providers: Optional context providers. + middleware: Optional agent-level middleware. + client_type: Custom client class (must subclass ``RawFoundryAgentChatClient``). + env_file_path: Path to .env file for settings. + env_file_encoding: Encoding for .env file. + kwargs: Additional keyword arguments. + """ + super().__init__( + project_endpoint=project_endpoint, + agent_name=agent_name, + agent_version=agent_version, + credential=credential, + project_client=project_client, + allow_preview=allow_preview, + tools=tools, + context_providers=context_providers, + middleware=middleware, + client_type=client_type, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + **kwargs, + ) diff --git a/python/packages/foundry/agent_framework_foundry/_foundry_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py similarity index 92% rename from python/packages/foundry/agent_framework_foundry/_foundry_chat_client.py rename to python/packages/foundry/agent_framework_foundry/_chat_client.py index 8397f29639..51d1b96bb3 100644 --- a/python/packages/foundry/agent_framework_foundry/_foundry_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -4,14 +4,17 @@ from __future__ import annotations import logging import sys -from collections.abc import Sequence +from collections.abc import Awaitable, Callable, Sequence from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal -from agent_framework._middleware import ChatMiddlewareLayer -from agent_framework._settings import load_settings -from agent_framework._telemetry import AGENT_FRAMEWORK_USER_AGENT -from agent_framework._tools import FunctionInvocationConfiguration, FunctionInvocationLayer -from agent_framework._types import Content +from agent_framework import ( + AGENT_FRAMEWORK_USER_AGENT, + ChatMiddlewareLayer, + Content, + FunctionInvocationConfiguration, + FunctionInvocationLayer, + load_settings, +) from agent_framework.observability import ChatTelemetryLayer from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient from azure.ai.projects.aio import AIProjectClient @@ -25,9 +28,8 @@ from azure.ai.projects.models import ( ) from azure.ai.projects.models import FileSearchTool as ProjectsFileSearchTool from azure.ai.projects.models import MCPTool as FoundryMCPTool - -from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider -from ._shared import resolve_file_ids +from azure.core.credentials import TokenCredential +from azure.core.credentials_async import AsyncTokenCredential if sys.version_info >= (3, 13): from typing import TypeVar # type: ignore # pragma: no cover @@ -43,15 +45,13 @@ else: from typing_extensions import TypedDict # type: ignore # pragma: no cover if TYPE_CHECKING: - from agent_framework._middleware import ( - ChatMiddleware, - ChatMiddlewareCallable, - FunctionMiddleware, - FunctionMiddlewareCallable, - ) + from agent_framework import ChatAndFunctionMiddlewareTypes logger: logging.Logger = logging.getLogger("agent_framework.foundry") +AzureTokenProvider = Callable[[], str | Awaitable[str]] +AzureCredentialTypes = TokenCredential | AsyncTokenCredential + class FoundrySettings(TypedDict, total=False): """Settings for Microsoft FoundryChatClient resolved from args and environment. @@ -67,6 +67,33 @@ class FoundrySettings(TypedDict, total=False): project_endpoint: str | None +def resolve_file_ids(file_ids: Sequence[str | Content] | None) -> list[str] | None: + """Resolve file IDs from strings or hosted-file Content objects.""" + if not file_ids: + return None + + resolved: list[str] = [] + for item in file_ids: + if isinstance(item, str): + if not item: + raise ValueError("file_ids must not contain empty strings.") + resolved.append(item) + elif isinstance(item, Content): + if item.type != "hosted_file": + raise ValueError( + f"Unsupported Content type {item.type!r} for code interpreter file_ids. " + "Only Content.from_hosted_file() is supported." + ) + if item.file_id is None: + raise ValueError( + "Content.from_hosted_file() item is missing a file_id. " + "Ensure the Content object has a valid file_id before using it in file_ids." + ) + resolved.append(item.file_id) + + return resolved if resolved else None + + FoundryChatOptionsT = TypeVar( "FoundryChatOptionsT", bound=TypedDict, # type: ignore[valid-type] @@ -492,9 +519,7 @@ class FoundryChatClient( # type: ignore[misc] env_file_path: str | None = None, env_file_encoding: str | None = None, instruction_role: str | None = None, - middleware: ( - Sequence[ChatMiddleware | ChatMiddlewareCallable | FunctionMiddleware | FunctionMiddlewareCallable] | None - ) = None, + middleware: (Sequence[ChatAndFunctionMiddlewareTypes] | None) = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, **kwargs: Any, ) -> None: diff --git a/python/packages/foundry/agent_framework_foundry/_entra_id_authentication.py b/python/packages/foundry/agent_framework_foundry/_entra_id_authentication.py deleted file mode 100644 index b1ae8a4739..0000000000 --- a/python/packages/foundry/agent_framework_foundry/_entra_id_authentication.py +++ /dev/null @@ -1,67 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -import logging -from collections.abc import Awaitable, Callable -from typing import Union - -from agent_framework.exceptions import ChatClientInvalidAuthException -from azure.core.credentials import TokenCredential -from azure.core.credentials_async import AsyncTokenCredential - -logger: logging.Logger = logging.getLogger(__name__) - -AzureTokenProvider = Callable[[], Union[str, Awaitable[str]]] -"""A callable that returns a bearer token string, either synchronously or asynchronously.""" - -AzureCredentialTypes = Union[TokenCredential, AsyncTokenCredential] -"""Union of Azure credential types. - -Accepts: -- ``TokenCredential`` — synchronous Azure credential (e.g. ``DefaultAzureCredential()``) -- ``AsyncTokenCredential`` — asynchronous Azure credential (e.g. ``azure.identity.aio.DefaultAzureCredential()``) -""" - - -def resolve_credential_to_token_provider( - credential: AzureCredentialTypes | AzureTokenProvider, - token_endpoint: str | None, -) -> AzureTokenProvider: - """Convert an Azure credential or token provider into an ``ad_token_provider`` callable. - - If the credential is already a callable token provider, it is returned as-is - (``token_endpoint`` is not required in this case). - If it is a ``TokenCredential`` or ``AsyncTokenCredential``, it is wrapped using - ``azure.identity.get_bearer_token_provider`` (sync or async variant) which - handles token caching and automatic refresh. - - Args: - credential: An Azure credential or token provider callable. - token_endpoint: The token scope/endpoint - (e.g. ``"https://cognitiveservices.azure.com/.default"``). - Required when ``credential`` is a ``TokenCredential`` or ``AsyncTokenCredential``. - - Returns: - A callable that returns a bearer token string (sync or async). - - Raises: - ServiceInvalidAuthError: If the token endpoint is empty when needed for credential wrapping. - """ - # Already a token provider callable (not a credential object) — use directly - if callable(credential) and not isinstance(credential, (TokenCredential, AsyncTokenCredential)): - return credential - - if not token_endpoint: - raise ChatClientInvalidAuthException( - "A token endpoint must be provided either in settings, as an environment variable, or as an argument." - ) - - if isinstance(credential, AsyncTokenCredential): - from azure.identity.aio import get_bearer_token_provider as get_async_bearer_token_provider - - return get_async_bearer_token_provider(credential, token_endpoint) - - from azure.identity import get_bearer_token_provider - - return get_bearer_token_provider(credential, token_endpoint) # type: ignore[arg-type] diff --git a/python/packages/foundry/agent_framework_foundry/_foundry_agent.py b/python/packages/foundry/agent_framework_foundry/_foundry_agent.py deleted file mode 100644 index 19c298eaf8..0000000000 --- a/python/packages/foundry/agent_framework_foundry/_foundry_agent.py +++ /dev/null @@ -1,287 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Microsoft Foundry Agent for connecting to pre-configured agents in Foundry. - -This module provides ``RawFoundryAgent`` and ``FoundryAgent`` — Agent subclasses -that connect to existing PromptAgents or HostedAgents in Foundry. Use -``FoundryAgent`` for the recommended experience with full middleware and telemetry. -""" - -from __future__ import annotations - -import logging -import sys -from collections.abc import Callable, Sequence -from typing import TYPE_CHECKING, Any - -from agent_framework import ( - AgentMiddlewareLayer, - BaseContextProvider, - RawAgent, -) -from agent_framework.observability import AgentTelemetryLayer -from azure.ai.projects.aio import AIProjectClient - -from ._entra_id_authentication import AzureCredentialTypes -from ._foundry_agent_client import ( - RawFoundryAgentChatClient, - _FoundryAgentChatClient, # pyright: ignore[reportPrivateUsage] -) - -if sys.version_info >= (3, 13): - from typing import TypeVar # type: ignore # pragma: no cover -else: - from typing_extensions import TypeVar # type: ignore # pragma: no cover -if sys.version_info >= (3, 11): - from typing import TypedDict # type: ignore # pragma: no cover -else: - from typing_extensions import TypedDict # type: ignore # pragma: no cover - -if TYPE_CHECKING: - from agent_framework._middleware import MiddlewareTypes - from agent_framework._tools import FunctionTool - from agent_framework_openai._chat_client import OpenAIChatOptions - -logger: logging.Logger = logging.getLogger("agent_framework.foundry") - -FoundryAgentOptionsT = TypeVar( - "FoundryAgentOptionsT", - bound=TypedDict, # type: ignore[valid-type] - default="OpenAIChatOptions", - covariant=True, -) - - -class RawFoundryAgent( # type: ignore[misc] - RawAgent[FoundryAgentOptionsT], -): - """Raw Microsoft Foundry Agent without agent-level middleware or telemetry. - - Connects to an existing PromptAgent or HostedAgent in Foundry. - For full middleware and telemetry support, use :class:`FoundryAgent`. - - Examples: - .. code-block:: python - - from agent_framework.foundry import RawFoundryAgent - from azure.identity import AzureCliCredential - - agent = RawFoundryAgent( - project_endpoint="https://your-project.services.ai.azure.com", - agent_name="my-prompt-agent", - agent_version="1.0", - credential=AzureCliCredential(), - ) - result = await agent.run("Hello!") - """ - - def __init__( - self, - *, - project_endpoint: str | None = None, - agent_name: str | None = None, - agent_version: str | None = None, - credential: AzureCredentialTypes | None = None, - project_client: AIProjectClient | None = None, - allow_preview: bool | None = None, - tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, - client_type: type[RawFoundryAgentChatClient] | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - **kwargs: Any, - ) -> None: - """Initialize a Foundry Agent. - - Keyword Args: - project_endpoint: The Foundry project endpoint URL. - Can also be set via environment variable FOUNDRY_PROJECT_ENDPOINT. - agent_name: The name of the Foundry agent to connect to. - Can also be set via environment variable FOUNDRY_AGENT_NAME. - agent_version: The version of the agent (required for PromptAgents, optional for HostedAgents). - Can also be set via environment variable FOUNDRY_AGENT_VERSION. - credential: Azure credential for authentication. - project_client: An existing AIProjectClient to use. - allow_preview: Enables preview opt-in on internally-created AIProjectClient. - tools: Function tools to provide to the agent. Only ``FunctionTool`` objects are accepted. - context_providers: Optional context providers for injecting dynamic context. - client_type: Custom client class to use (must be a subclass of ``RawFoundryAgentChatClient``). - Defaults to ``_FoundryAgentChatClient`` (full client middleware). - env_file_path: Path to .env file for settings. - env_file_encoding: Encoding for .env file. - kwargs: Additional keyword arguments passed to the Agent base class. - """ - # Create the client - actual_client_type = client_type or _FoundryAgentChatClient - if not issubclass(actual_client_type, RawFoundryAgentChatClient): - raise TypeError( - f"client_type must be a subclass of RawFoundryAgentChatClient, got {actual_client_type.__name__}" - ) - - client = actual_client_type( - project_endpoint=project_endpoint, - agent_name=agent_name, - agent_version=agent_version, - credential=credential, - project_client=project_client, - allow_preview=allow_preview, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - - super().__init__( - client=client, # type: ignore[arg-type] - tools=tools, # type: ignore[arg-type] - context_providers=context_providers, - **kwargs, - ) - - async def configure_azure_monitor( - self, - enable_sensitive_data: bool = False, - **kwargs: Any, - ) -> None: - """Setup observability with Azure Monitor (Microsoft Foundry integration). - - This method configures Azure Monitor for telemetry collection using the - connection string from the Foundry project client (accessed via the internal client). - - Args: - enable_sensitive_data: Enable sensitive data logging (prompts, responses). - Should only be enabled in development/test environments. Default is False. - **kwargs: Additional arguments passed to configure_azure_monitor(). - - Raises: - ImportError: If azure-monitor-opentelemetry-exporter is not installed. - """ - from azure.core.exceptions import ResourceNotFoundError - - from ._foundry_agent_client import RawFoundryAgentChatClient - - client = self.client - if not isinstance(client, RawFoundryAgentChatClient): - raise TypeError("configure_azure_monitor requires a RawFoundryAgentChatClient-based client.") - - try: - conn_string = await client.project_client.telemetry.get_application_insights_connection_string() - except ResourceNotFoundError: - logger.warning( - "No Application Insights connection string found for the Foundry project. " - "Please ensure Application Insights is configured in your project, " - "or call configure_otel_providers() manually with custom exporters." - ) - return - - try: - from azure.monitor.opentelemetry import configure_azure_monitor # type: ignore[import] - except ImportError as exc: - raise ImportError( - "azure-monitor-opentelemetry is required for Azure Monitor integration. " - "Install it with: pip install azure-monitor-opentelemetry" - ) from exc - - from agent_framework.observability import create_metric_views, create_resource, enable_instrumentation - - if "resource" not in kwargs: - kwargs["resource"] = create_resource() - - configure_azure_monitor( - connection_string=conn_string, - views=create_metric_views(), - **kwargs, - ) - - enable_instrumentation(enable_sensitive_data=enable_sensitive_data) - - -class FoundryAgent( # type: ignore[misc] - AgentMiddlewareLayer, - AgentTelemetryLayer, - RawFoundryAgent[FoundryAgentOptionsT], -): - """Microsoft Foundry Agent with full middleware and telemetry support. - - Connects to an existing PromptAgent or HostedAgent in Foundry. - This is the recommended class for production use. - - Examples: - .. code-block:: python - - from agent_framework.foundry import FoundryAgent - from azure.identity import AzureCliCredential - - # Connect to a PromptAgent - agent = FoundryAgent( - project_endpoint="https://your-project.services.ai.azure.com", - agent_name="my-prompt-agent", - agent_version="1.0", - credential=AzureCliCredential(), - tools=[my_function_tool], - ) - result = await agent.run("Hello!") - - # Connect to a HostedAgent (no version needed) - agent = FoundryAgent( - project_endpoint="https://your-project.services.ai.azure.com", - agent_name="my-hosted-agent", - credential=AzureCliCredential(), - ) - - # Custom client (e.g., raw client without client middleware) - agent = FoundryAgent( - project_endpoint="https://your-project.services.ai.azure.com", - agent_name="my-agent", - credential=AzureCliCredential(), - client_type=RawFoundryAgentChatClient, - ) - """ - - def __init__( - self, - *, - project_endpoint: str | None = None, - agent_name: str | None = None, - agent_version: str | None = None, - credential: AzureCredentialTypes | None = None, - project_client: AIProjectClient | None = None, - allow_preview: bool | None = None, - tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - client_type: type[RawFoundryAgentChatClient] | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - **kwargs: Any, - ) -> None: - """Initialize a Foundry Agent with full middleware and telemetry. - - Keyword Args: - project_endpoint: The Foundry project endpoint URL. - agent_name: The name of the Foundry agent to connect to. - agent_version: The version of the agent (for PromptAgents). - credential: Azure credential for authentication. - project_client: An existing AIProjectClient to use. - allow_preview: Enables preview opt-in on internally-created AIProjectClient. - tools: Function tools to provide to the agent. Only ``FunctionTool`` objects are accepted. - context_providers: Optional context providers. - middleware: Optional agent-level middleware. - client_type: Custom client class (must subclass ``RawFoundryAgentChatClient``). - env_file_path: Path to .env file for settings. - env_file_encoding: Encoding for .env file. - kwargs: Additional keyword arguments. - """ - super().__init__( - project_endpoint=project_endpoint, - agent_name=agent_name, - agent_version=agent_version, - credential=credential, - project_client=project_client, - allow_preview=allow_preview, - tools=tools, - context_providers=context_providers, - middleware=middleware, - client_type=client_type, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - **kwargs, - ) diff --git a/python/packages/foundry/agent_framework_foundry/_foundry_memory_provider.py b/python/packages/foundry/agent_framework_foundry/_memory_provider.py similarity index 94% rename from python/packages/foundry/agent_framework_foundry/_foundry_memory_provider.py rename to python/packages/foundry/agent_framework_foundry/_memory_provider.py index 3c24e3380e..36d4a27a43 100644 --- a/python/packages/foundry/agent_framework_foundry/_foundry_memory_provider.py +++ b/python/packages/foundry/agent_framework_foundry/_memory_provider.py @@ -13,25 +13,38 @@ import sys from contextlib import AbstractAsyncContextManager from typing import TYPE_CHECKING, Any, ClassVar -from agent_framework import AGENT_FRAMEWORK_USER_AGENT, Message -from agent_framework._sessions import AgentSession, BaseContextProvider, SessionContext -from agent_framework._settings import load_settings +from agent_framework import ( + AGENT_FRAMEWORK_USER_AGENT, + AgentSession, + BaseContextProvider, + Message, + SessionContext, + load_settings, +) from azure.ai.projects.aio import AIProjectClient +from azure.core.credentials import TokenCredential +from azure.core.credentials_async import AsyncTokenCredential from openai.types.responses import ResponseInputItemParam -from ._entra_id_authentication import AzureCredentialTypes -from ._shared import FoundryProjectSettings - if sys.version_info >= (3, 11): - from typing import Self # pragma: no cover + from typing import Self, TypedDict # pragma: no cover else: - from typing_extensions import Self # pragma: no cover + from typing_extensions import Self, TypedDict # pragma: no cover if TYPE_CHECKING: - from agent_framework._agents import SupportsAgentRun + from agent_framework import SupportsAgentRun + logger = logging.getLogger(__name__) +AzureCredentialTypes = TokenCredential | AsyncTokenCredential + + +class FoundryProjectSettings(TypedDict, total=False): + """Foundry project settings loaded from FOUNDRY_ environment variables.""" + + project_endpoint: str | None + class FoundryMemoryProvider(BaseContextProvider): """Foundry Memory context provider using the new BaseContextProvider hooks pattern. diff --git a/python/packages/foundry/agent_framework_foundry/_shared.py b/python/packages/foundry/agent_framework_foundry/_shared.py deleted file mode 100644 index 8eed50f067..0000000000 --- a/python/packages/foundry/agent_framework_foundry/_shared.py +++ /dev/null @@ -1,49 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -import logging -import sys -from collections.abc import Sequence - -from agent_framework import Content - -if sys.version_info >= (3, 11): - from typing import TypedDict # pragma: no cover -else: - from typing_extensions import TypedDict # type: ignore # pragma: no cover - -logger = logging.getLogger("agent_framework.foundry") - - -class FoundryProjectSettings(TypedDict, total=False): - """Foundry project settings loaded from FOUNDRY_ environment variables.""" - - project_endpoint: str | None - - -def resolve_file_ids(file_ids: Sequence[str | Content] | None) -> list[str] | None: - """Resolve file IDs from strings or hosted-file Content objects.""" - if not file_ids: - return None - - resolved: list[str] = [] - for item in file_ids: - if isinstance(item, str): - if not item: - raise ValueError("file_ids must not contain empty strings.") - resolved.append(item) - elif isinstance(item, Content): - if item.type != "hosted_file": - raise ValueError( - f"Unsupported Content type {item.type!r} for code interpreter file_ids. " - "Only Content.from_hosted_file() is supported." - ) - if item.file_id is None: - raise ValueError( - "Content.from_hosted_file() item is missing a file_id. " - "Ensure the Content object has a valid file_id before using it in file_ids." - ) - resolved.append(item.file_id) - - return resolved if resolved else None diff --git a/python/packages/foundry/tests/assets/sample_image.jpg b/python/packages/foundry/tests/assets/sample_image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ea6486656fd5b603af043e29b941c99845baea7a GIT binary patch literal 182161 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%0Dk-Pm;7s}kHuGa7vE${d#SHv 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 UcKRcD&9zq&ZLV4!^WzTS~D|(tTOER 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 B_m}_x literal 0 HcmV?d00001 diff --git a/python/packages/foundry/tests/conftest.py b/python/packages/foundry/tests/foundry/conftest.py similarity index 100% rename from python/packages/foundry/tests/conftest.py rename to python/packages/foundry/tests/foundry/conftest.py diff --git a/python/packages/foundry/tests/foundry/test_foundry_agent.py b/python/packages/foundry/tests/foundry/test_foundry_agent.py new file mode 100644 index 0000000000..2eb992d1a2 --- /dev/null +++ b/python/packages/foundry/tests/foundry/test_foundry_agent.py @@ -0,0 +1,413 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import os +import sys +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from agent_framework import AgentResponse, ChatContext, ChatMiddleware, Message, tool +from azure.core.exceptions import ResourceNotFoundError +from azure.identity import AzureCliCredential + +from agent_framework_foundry._agent import ( + FoundryAgent, + RawFoundryAgent, + RawFoundryAgentChatClient, + _FoundryAgentChatClient, +) + +skip_if_foundry_agent_integration_tests_disabled = pytest.mark.skipif( + os.getenv("FOUNDRY_PROJECT_ENDPOINT", "") in ("", "https://test-project.services.ai.azure.com/") + or os.getenv("FOUNDRY_AGENT_NAME", "") == "", + reason="No real FOUNDRY_PROJECT_ENDPOINT or FOUNDRY_AGENT_NAME provided; skipping integration tests.", +) + +_FOUNDRY_AGENT_ENV_VARS = ( + "FOUNDRY_PROJECT_ENDPOINT", + "FOUNDRY_AGENT_NAME", + "FOUNDRY_AGENT_VERSION", +) + + +@pytest.fixture(autouse=True) +def clear_foundry_agent_settings_env(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> None: + """Prevent unit tests from inheriting Foundry agent settings from the shell.""" + + if request.node.get_closest_marker("integration") is not None: + return + + for env_var in _FOUNDRY_AGENT_ENV_VARS: + monkeypatch.delenv(env_var, raising=False) + + +def test_raw_foundry_agent_chat_client_init_requires_agent_name() -> None: + """Test that agent_name is required.""" + + with pytest.raises(ValueError, match="Agent name is required"): + RawFoundryAgentChatClient( + project_client=MagicMock(), + ) + + +def test_raw_foundry_agent_chat_client_init_with_agent_name() -> None: + """Test construction with agent_name and project_client.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + agent_version="1.0", + ) + + assert client.agent_name == "test-agent" + assert client.agent_version == "1.0" + + +def test_raw_foundry_agent_chat_client_get_agent_reference_with_version() -> None: + """Test agent reference includes version when provided.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="my-agent", + agent_version="2.0", + ) + + ref = client._get_agent_reference() + assert ref == {"name": "my-agent", "version": "2.0", "type": "agent_reference"} + + +def test_raw_foundry_agent_chat_client_get_agent_reference_without_version() -> None: + """Test agent reference omits version for HostedAgents.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="hosted-agent", + ) + + ref = client._get_agent_reference() + assert ref == {"name": "hosted-agent", "type": "agent_reference"} + assert "version" not in ref + + +def test_raw_foundry_agent_chat_client_as_agent_preserves_client_type() -> None: + """Test that as_agent() wraps the client in FoundryAgent using the same client class.""" + + class CustomClient(RawFoundryAgentChatClient): + pass + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = CustomClient( + project_client=mock_project, + agent_name="test-agent", + agent_version="1.0", + ) + + agent = client.as_agent(instructions="You are helpful.") + + assert isinstance(agent, FoundryAgent) + assert agent.name == "test-agent" + assert isinstance(agent.client, CustomClient) + assert agent.client.project_client is mock_project + assert agent.client.agent_name == "test-agent" + assert agent.client.agent_version == "1.0" + + named_agent = client.as_agent(name="display-name", instructions="You are helpful.") + assert named_agent.name == "display-name" + assert named_agent.client.agent_name == "test-agent" + + +async def test_raw_foundry_agent_chat_client_prepare_options_validates_tools() -> None: + """Test that _prepare_options rejects non-FunctionTool objects.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + with pytest.raises(TypeError, match="Only FunctionTool objects are accepted"): + await client._prepare_options( + messages=[Message(role="user", contents="hi")], + options={"tools": [{"type": "function", "function": {"name": "bad"}}]}, + ) + + +async def test_raw_foundry_agent_chat_client_prepare_options_accepts_function_tools() -> None: + """Test that _prepare_options accepts FunctionTool objects.""" + + mock_project = MagicMock() + mock_openai = MagicMock() + mock_project.get_openai_client.return_value = mock_openai + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + @tool(approval_mode="never_require") + def my_func() -> str: + """A test function.""" + + return "ok" + + with patch( + "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", + new_callable=AsyncMock, + return_value={}, + ): + result = await client._prepare_options( + messages=[Message(role="user", contents="hi")], + options={"tools": [my_func]}, + ) + + assert "extra_body" in result + assert result["extra_body"]["agent_reference"]["name"] == "test-agent" + + +def test_raw_foundry_agent_chat_client_check_model_presence_is_noop() -> None: + """Test that _check_model_presence does nothing (model is on service).""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + options: dict[str, Any] = {} + client._check_model_presence(options) + assert "model" not in options + + +def test_foundry_agent_chat_client_init() -> None: + """Test construction of the full-middleware client.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = _FoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + agent_version="1.0", + ) + + assert client.agent_name == "test-agent" + + +def test_raw_foundry_agent_init_creates_client() -> None: + """Test that RawFoundryAgent creates a client internally.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + agent = RawFoundryAgent( + project_client=mock_project, + agent_name="test-agent", + agent_version="1.0", + ) + + assert agent.client is not None + assert agent.client.agent_name == "test-agent" + + +def test_raw_foundry_agent_init_with_custom_client_type() -> None: + """Test that client_type parameter is respected.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + agent = RawFoundryAgent( + project_client=mock_project, + agent_name="test-agent", + client_type=RawFoundryAgentChatClient, + ) + + assert isinstance(agent.client, RawFoundryAgentChatClient) + + +def test_raw_foundry_agent_init_rejects_invalid_client_type() -> None: + """Test that invalid client_type raises TypeError.""" + + with pytest.raises(TypeError, match="must be a subclass of RawFoundryAgentChatClient"): + RawFoundryAgent( + project_client=MagicMock(), + agent_name="test-agent", + client_type=object, # type: ignore[arg-type] + ) + + +def test_raw_foundry_agent_init_with_function_tools() -> None: + """Test that FunctionTool and callables are accepted.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + @tool(approval_mode="never_require") + def my_func() -> str: + """A test function.""" + + return "ok" + + agent = RawFoundryAgent( + project_client=mock_project, + agent_name="test-agent", + tools=[my_func], + ) + + assert agent.default_options.get("tools") is not None + + +def test_foundry_agent_init() -> None: + """Test construction of the full-middleware agent.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + agent = FoundryAgent( + project_client=mock_project, + agent_name="test-agent", + agent_version="1.0", + ) + + assert agent.client is not None + assert agent.client.agent_name == "test-agent" + + +def test_foundry_agent_init_with_middleware() -> None: + """Test that agent-level middleware is accepted.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + class MyMiddleware(ChatMiddleware): + async def process(self, context: ChatContext) -> None: + pass + + agent = FoundryAgent( + project_client=mock_project, + agent_name="test-agent", + middleware=[MyMiddleware()], + ) + + assert agent.client is not None + + +async def test_foundry_agent_configure_azure_monitor() -> None: + """Test configure_azure_monitor delegates through the underlying client.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + mock_project.telemetry.get_application_insights_connection_string = AsyncMock( + return_value="InstrumentationKey=test-key;IngestionEndpoint=https://test.endpoint" + ) + agent = FoundryAgent(project_client=mock_project, agent_name="test-agent") + + mock_configure = MagicMock() + mock_views = MagicMock(return_value=[]) + mock_resource = MagicMock() + mock_enable = MagicMock() + + with ( + patch.dict( + "sys.modules", + {"azure.monitor.opentelemetry": MagicMock(configure_azure_monitor=mock_configure)}, + ), + patch("agent_framework.observability.create_metric_views", mock_views), + patch("agent_framework.observability.create_resource", return_value=mock_resource), + patch("agent_framework.observability.enable_instrumentation", mock_enable), + ): + await agent.configure_azure_monitor(enable_sensitive_data=True) + + mock_project.telemetry.get_application_insights_connection_string.assert_called_once() + call_kwargs = mock_configure.call_args.kwargs + assert call_kwargs["connection_string"] == "InstrumentationKey=test-key;IngestionEndpoint=https://test.endpoint" + assert call_kwargs["views"] == [] + assert call_kwargs["resource"] is mock_resource + mock_enable.assert_called_once_with(enable_sensitive_data=True) + + +async def test_foundry_agent_configure_azure_monitor_resource_not_found() -> None: + """Test configure_azure_monitor handles ResourceNotFoundError gracefully.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + mock_project.telemetry.get_application_insights_connection_string = AsyncMock( + side_effect=ResourceNotFoundError("No Application Insights found") + ) + agent = FoundryAgent(project_client=mock_project, agent_name="test-agent") + + await agent.configure_azure_monitor() + + mock_project.telemetry.get_application_insights_connection_string.assert_called_once() + + +async def test_foundry_agent_configure_azure_monitor_import_error() -> None: + """Test configure_azure_monitor raises ImportError when Azure Monitor is unavailable.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + mock_project.telemetry.get_application_insights_connection_string = AsyncMock( + return_value="InstrumentationKey=test-key" + ) + agent = FoundryAgent(project_client=mock_project, agent_name="test-agent") + original_import = __import__ + + def _import_with_missing_azure_monitor( + name: str, + globals: dict[str, Any] | None = None, + locals: dict[str, Any] | None = None, + fromlist: tuple[str, ...] = (), + level: int = 0, + ) -> Any: + if name == "azure.monitor.opentelemetry": + raise ImportError("No module named 'azure.monitor.opentelemetry'") + return original_import(name, globals, locals, fromlist, level) + + with ( + patch.dict(sys.modules, {"azure.monitor.opentelemetry": None}), + patch("builtins.__import__", side_effect=_import_with_missing_azure_monitor), + pytest.raises(ImportError, match="azure-monitor-opentelemetry is required"), + ): + await agent.configure_azure_monitor() + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_foundry_agent_integration_tests_disabled +async def test_foundry_agent_basic_run() -> None: + """Smoke-test FoundryAgent against a real configured agent.""" + async with FoundryAgent(credential=AzureCliCredential()) as agent: + response = await agent.run("Please respond with exactly: 'This is a response test.'") + + assert isinstance(response, AgentResponse) + assert response.text is not None + assert "response test" in response.text.lower() + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_foundry_agent_integration_tests_disabled +async def test_foundry_agent_custom_client_run() -> None: + """Smoke-test FoundryAgent against a real configured agent.""" + async with FoundryAgent(credential=AzureCliCredential(), client_type=RawFoundryAgentChatClient) as agent: + response = await agent.run("Please respond with exactly: 'This is a response test.'") + + assert isinstance(response, AgentResponse) + assert response.text is not None + assert "response test" in response.text.lower() diff --git a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py new file mode 100644 index 0000000000..7489be1896 --- /dev/null +++ b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py @@ -0,0 +1,751 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import json +import os +import sys +from functools import wraps +from pathlib import Path +from typing import Annotated, Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from agent_framework import ChatResponse, Content, Message, SupportsChatGetResponse, tool +from agent_framework._telemetry import AGENT_FRAMEWORK_USER_AGENT +from agent_framework.exceptions import ChatClientException, ChatClientInvalidRequestException +from agent_framework_openai import OpenAIContentFilterException +from azure.core.exceptions import ResourceNotFoundError +from azure.identity import AzureCliCredential +from openai import BadRequestError +from pydantic import BaseModel +from pytest import param + +from agent_framework_foundry import FoundryChatClient, RawFoundryChatClient + + +class OutputStruct(BaseModel): + """A structured output for testing purposes.""" + + location: str + weather: str | None = None + + +@tool(approval_mode="never_require") +async def get_weather(location: Annotated[str, "The location as a city name"]) -> str: + """Get the current weather in a given location.""" + return f"The current weather in {location} is sunny." + + +skip_if_foundry_integration_tests_disabled = pytest.mark.skipif( + os.getenv("FOUNDRY_PROJECT_ENDPOINT", "") in ("", "https://test-project.services.ai.azure.com/") + or os.getenv("FOUNDRY_MODEL", "") == "", + reason="No real FOUNDRY_PROJECT_ENDPOINT or FOUNDRY_MODEL provided; skipping integration tests.", +) + +_TEST_FOUNDRY_PROJECT_ENDPOINT = "https://test-project.services.ai.azure.com/" +_TEST_FOUNDRY_MODEL = "test-gpt-4o" +_FOUNDRY_CHAT_ENV_VARS = ("FOUNDRY_PROJECT_ENDPOINT", "FOUNDRY_MODEL") + + +@pytest.fixture(autouse=True) +def clear_foundry_chat_settings_env(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> None: + """Prevent unit tests from inheriting Foundry chat settings from the shell.""" + + if request.node.get_closest_marker("integration") is not None: + return + + for env_var in _FOUNDRY_CHAT_ENV_VARS: + monkeypatch.delenv(env_var, raising=False) + + +def _with_foundry_debug() -> Any: + def decorator(func: Any) -> Any: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return await func(*args, **kwargs) + except Exception as exc: + debug_message = ( + "Foundry debug: " + f"project_endpoint={os.getenv('FOUNDRY_PROJECT_ENDPOINT', '')}, " + f"model={os.getenv('FOUNDRY_MODEL', '')}" + ) + if hasattr(exc, "add_note"): + exc.add_note(debug_message) + elif exc.args: + exc.args = (f"{exc.args[0]}\n{debug_message}", *exc.args[1:]) + else: + exc.args = (debug_message,) + raise + + return wrapper + + return decorator + + +def _make_mock_openai_client() -> MagicMock: + client = MagicMock() + client.default_headers = {} + client.responses = MagicMock() + client.responses.create = AsyncMock() + client.responses.parse = AsyncMock() + client.files = MagicMock() + client.files.create = AsyncMock() + client.files.delete = AsyncMock() + client.vector_stores = MagicMock() + client.vector_stores.create = AsyncMock() + client.vector_stores.delete = AsyncMock() + client.vector_stores.files = MagicMock() + client.vector_stores.files.create_and_poll = AsyncMock() + return client + + +async def create_vector_store(client: FoundryChatClient) -> tuple[str, Content]: + """Create a vector store with sample documents for testing.""" + file = await client.client.files.create( + file=("todays_weather.txt", b"The weather today is sunny with a high of 75F."), + purpose="user_data", + ) + vector_store = await client.client.vector_stores.create( + name="knowledge_base", + expires_after={"anchor": "last_active_at", "days": 1}, + ) + result = await client.client.vector_stores.files.create_and_poll( + vector_store_id=vector_store.id, + file_id=file.id, + poll_interval_ms=1000, + ) + if result.last_error is not None: + raise RuntimeError(f"Vector store file processing failed with status: {result.last_error.message}") + + return file.id, Content.from_hosted_vector_store(vector_store_id=vector_store.id) + + +async def delete_vector_store(client: FoundryChatClient, file_id: str, vector_store_id: str) -> None: + """Delete the vector store after tests.""" + await client.client.vector_stores.delete(vector_store_id=vector_store_id) + await client.client.files.delete(file_id=file_id) + + +def test_init() -> None: + mock_openai_client = _make_mock_openai_client() + mock_project_client = MagicMock() + mock_project_client.get_openai_client.return_value = mock_openai_client + + client = FoundryChatClient(project_client=mock_project_client, model=_TEST_FOUNDRY_MODEL) + + assert client.model == _TEST_FOUNDRY_MODEL + assert isinstance(client, SupportsChatGetResponse) + assert client.project_client is mock_project_client + + +def test_init_with_default_header() -> None: + default_headers = {"X-Unit-Test": "test-guid"} + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + + client = FoundryChatClient( + project_client=project_client, + model=_TEST_FOUNDRY_MODEL, + default_headers=default_headers, + ) + + assert client.model == _TEST_FOUNDRY_MODEL + for key, value in default_headers.items(): + assert client.default_headers is not None + assert key in client.default_headers + assert client.default_headers[key] == value + + +def test_init_with_project_endpoint_creates_project_client() -> None: + credential = MagicMock() + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + + with patch("agent_framework_foundry._chat_client.AIProjectClient", return_value=project_client) as factory: + client = FoundryChatClient( + project_endpoint=_TEST_FOUNDRY_PROJECT_ENDPOINT, + model=_TEST_FOUNDRY_MODEL, + credential=credential, + allow_preview=True, + ) + + assert client.project_client is project_client + assert client.model == _TEST_FOUNDRY_MODEL + assert factory.call_args.kwargs["endpoint"] == _TEST_FOUNDRY_PROJECT_ENDPOINT + assert factory.call_args.kwargs["credential"] is credential + assert factory.call_args.kwargs["allow_preview"] is True + assert factory.call_args.kwargs["user_agent"] == AGENT_FRAMEWORK_USER_AGENT + + +def test_init_with_empty_model_raises(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("FOUNDRY_MODEL", raising=False) + mock_openai_client = _make_mock_openai_client() + mock_project_client = MagicMock() + mock_project_client.get_openai_client.return_value = mock_openai_client + + with pytest.raises(ValueError, match="Model is required"): + FoundryChatClient(project_client=mock_project_client) + + +def test_init_with_empty_project_source_raises(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("FOUNDRY_PROJECT_ENDPOINT", raising=False) + + with pytest.raises(ValueError, match="Either 'project_endpoint' or 'project_client' is required"): + FoundryChatClient(model=_TEST_FOUNDRY_MODEL) + + +def test_init_with_project_endpoint_requires_credential() -> None: + with pytest.raises(ValueError, match="Azure credential is required"): + FoundryChatClient( + project_endpoint=_TEST_FOUNDRY_PROJECT_ENDPOINT, + model=_TEST_FOUNDRY_MODEL, + ) + + +async def test_configure_azure_monitor() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + project_client.telemetry.get_application_insights_connection_string = AsyncMock( + return_value="InstrumentationKey=test-key;IngestionEndpoint=https://test.endpoint" + ) + client = FoundryChatClient(project_client=project_client, model=_TEST_FOUNDRY_MODEL) + + mock_configure = MagicMock() + mock_views = MagicMock(return_value=[]) + mock_resource = MagicMock() + mock_enable = MagicMock() + + with ( + patch.dict( + "sys.modules", + {"azure.monitor.opentelemetry": MagicMock(configure_azure_monitor=mock_configure)}, + ), + patch("agent_framework.observability.create_metric_views", mock_views), + patch("agent_framework.observability.create_resource", return_value=mock_resource), + patch("agent_framework.observability.enable_instrumentation", mock_enable), + ): + await client.configure_azure_monitor(enable_sensitive_data=True) + + project_client.telemetry.get_application_insights_connection_string.assert_called_once() + mock_configure.assert_called_once() + call_kwargs = mock_configure.call_args.kwargs + assert call_kwargs["connection_string"] == "InstrumentationKey=test-key;IngestionEndpoint=https://test.endpoint" + assert call_kwargs["views"] == [] + assert call_kwargs["resource"] is mock_resource + mock_enable.assert_called_once_with(enable_sensitive_data=True) + + +async def test_configure_azure_monitor_resource_not_found() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + project_client.telemetry.get_application_insights_connection_string = AsyncMock( + side_effect=ResourceNotFoundError("No Application Insights found") + ) + client = FoundryChatClient(project_client=project_client, model=_TEST_FOUNDRY_MODEL) + + await client.configure_azure_monitor() + + project_client.telemetry.get_application_insights_connection_string.assert_called_once() + + +async def test_configure_azure_monitor_import_error() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + project_client.telemetry.get_application_insights_connection_string = AsyncMock( + return_value="InstrumentationKey=test-key" + ) + client = FoundryChatClient(project_client=project_client, model=_TEST_FOUNDRY_MODEL) + original_import = __import__ + + def _import_with_missing_azure_monitor( + name: str, + globals: dict[str, Any] | None = None, + locals: dict[str, Any] | None = None, + fromlist: tuple[str, ...] = (), + level: int = 0, + ) -> Any: + if name == "azure.monitor.opentelemetry": + raise ImportError("No module named 'azure.monitor.opentelemetry'") + return original_import(name, globals, locals, fromlist, level) + + with ( + patch.dict(sys.modules, {"azure.monitor.opentelemetry": None}), + patch("builtins.__import__", side_effect=_import_with_missing_azure_monitor), + pytest.raises(ImportError, match="azure-monitor-opentelemetry is required"), + ): + await client.configure_azure_monitor() + + +async def test_configure_azure_monitor_with_custom_resource() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + project_client.telemetry.get_application_insights_connection_string = AsyncMock( + return_value="InstrumentationKey=test-key" + ) + client = FoundryChatClient(project_client=project_client, model=_TEST_FOUNDRY_MODEL) + + custom_resource = MagicMock() + mock_configure = MagicMock() + + with ( + patch.dict( + "sys.modules", + {"azure.monitor.opentelemetry": MagicMock(configure_azure_monitor=mock_configure)}, + ), + patch("agent_framework.observability.create_metric_views", return_value=[]), + patch("agent_framework.observability.create_resource") as mock_create_resource, + patch("agent_framework.observability.enable_instrumentation"), + ): + await client.configure_azure_monitor(resource=custom_resource) + + mock_create_resource.assert_not_called() + call_kwargs = mock_configure.call_args.kwargs + assert call_kwargs["resource"] is custom_resource + + +async def test_get_response_with_invalid_input() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + client = FoundryChatClient(project_client=project_client, model="test-model") + + with pytest.raises(ChatClientInvalidRequestException, match="Messages are required"): + await client.get_response(messages=[]) + + +async def test_web_search_tool_with_location() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + client = FoundryChatClient(project_client=project_client, model="test-model") + + web_search_tool = FoundryChatClient.get_web_search_tool( + user_location={ + "city": "Seattle", + "country": "US", + "region": "WA", + "timezone": "America/Los_Angeles", + } + ) + + assert web_search_tool.user_location.city == "Seattle" + assert web_search_tool.user_location.country == "US" + _, run_options, _ = await client._prepare_request( + messages=[Message(role="user", text="What's the weather?")], + options={"tools": [web_search_tool], "tool_choice": "auto"}, + ) + + assert run_options["tools"] == [web_search_tool] + assert run_options["tool_choice"] == "auto" + + +async def test_code_interpreter_tool_variations() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + client = FoundryChatClient(project_client=project_client, model="test-model") + + code_tool = FoundryChatClient.get_code_interpreter_tool() + assert code_tool.container["type"] == "auto" + + _, run_options, _ = await client._prepare_request( + messages=[Message("user", ["Run some code"])], + options={"tools": [code_tool]}, + ) + + assert run_options["tools"] == [code_tool] + + code_tool_with_files = FoundryChatClient.get_code_interpreter_tool(file_ids=["file1", "file2"]) + assert code_tool_with_files.container.file_ids == ["file1", "file2"] + + _, run_options, _ = await client._prepare_request( + messages=[Message(role="user", text="Process these files")], + options={"tools": [code_tool_with_files]}, + ) + + assert run_options["tools"] == [code_tool_with_files] + + +async def test_hosted_file_search_tool_validation() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + client = FoundryChatClient(project_client=project_client, model="test-model") + + with pytest.raises(ValueError, match="vector_store_ids"): + FoundryChatClient.get_file_search_tool(vector_store_ids=[]) + + file_search_tool = FoundryChatClient.get_file_search_tool(vector_store_ids=["vs_123"]) + assert file_search_tool.vector_store_ids == ["vs_123"] + + _, run_options, _ = await client._prepare_request( + messages=[Message("user", ["Test"])], + options={"tools": [file_search_tool]}, + ) + + assert run_options["tools"] == [file_search_tool] + + +async def test_chat_message_parsing_with_function_calls() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + client = FoundryChatClient(project_client=project_client, model="test-model") + + function_call = Content.from_function_call( + call_id="test-call-id", + name="test_function", + arguments='{"param": "value"}', + additional_properties={"fc_id": "test-fc-id"}, + ) + function_result = Content.from_function_result(call_id="test-call-id", result="Function executed successfully") + messages = [ + Message(role="user", text="Call a function"), + Message(role="assistant", contents=[function_call]), + Message(role="tool", contents=[function_result]), + ] + + prepared_messages = client._prepare_messages_for_openai(messages) + + assert prepared_messages == [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Call a function"}], + }, + { + "call_id": "test-call-id", + "id": "fc_test-fc-id", + "type": "function_call", + "name": "test_function", + "arguments": '{"param": "value"}', + }, + { + "call_id": "test-call-id", + "type": "function_call_output", + "output": "Function executed successfully", + }, + ] + + +async def test_content_filter_exception() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + client = FoundryChatClient(project_client=project_client, model="test-model") + + mock_error = BadRequestError( + message="Content filter error", + response=MagicMock(), + body={"error": {"code": "content_filter", "message": "Content filter error"}}, + ) + mock_error.code = "content_filter" + client.client.responses.create.side_effect = mock_error + + with pytest.raises(OpenAIContentFilterException) as exc_info: + await client.get_response(messages=[Message(role="user", text="Test message")]) + + assert "content error" in str(exc_info.value) + + +async def test_response_format_parse_path() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + client = FoundryChatClient(project_client=project_client, model="test-model") + + mock_parsed_response = MagicMock() + mock_parsed_response.id = "parsed_response_123" + mock_parsed_response.text = "Parsed response" + mock_parsed_response.model = "test-model" + mock_parsed_response.created_at = 1000000000 + mock_parsed_response.metadata = {} + mock_parsed_response.output_parsed = None + mock_parsed_response.usage = None + mock_parsed_response.finish_reason = None + mock_parsed_response.conversation = None + client.client.responses.parse = AsyncMock(return_value=mock_parsed_response) + + response = await client.get_response( + messages=[Message(role="user", text="Test message")], + options={"response_format": OutputStruct, "store": True}, + ) + assert response.response_id == "parsed_response_123" + assert response.conversation_id == "parsed_response_123" + assert response.model == "test-model" + + +async def test_response_format_parse_path_with_conversation_id() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + client = FoundryChatClient(project_client=project_client, model="test-model") + + mock_parsed_response = MagicMock() + mock_parsed_response.id = "parsed_response_123" + mock_parsed_response.text = "Parsed response" + mock_parsed_response.model = "test-model" + mock_parsed_response.created_at = 1000000000 + mock_parsed_response.metadata = {} + mock_parsed_response.output_parsed = None + mock_parsed_response.usage = None + mock_parsed_response.finish_reason = None + mock_parsed_response.conversation = MagicMock() + mock_parsed_response.conversation.id = "conversation_456" + client.client.responses.parse = AsyncMock(return_value=mock_parsed_response) + + response = await client.get_response( + messages=[Message(role="user", text="Test message")], + options={"response_format": OutputStruct, "store": True}, + ) + assert response.response_id == "parsed_response_123" + assert response.conversation_id == "conversation_456" + assert response.model == "test-model" + + +async def test_bad_request_error_non_content_filter() -> None: + mock_openai_client = _make_mock_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = mock_openai_client + client = FoundryChatClient(project_client=project_client, model="test-model") + + mock_error = BadRequestError( + message="Invalid request", + response=MagicMock(), + body={"error": {"code": "invalid_request", "message": "Invalid request"}}, + ) + mock_error.code = "invalid_request" + client.client.responses.parse = AsyncMock(side_effect=mock_error) + + with pytest.raises(ChatClientException) as exc_info: + await client.get_response( + messages=[Message(role="user", text="Test message")], + options={"response_format": OutputStruct}, + ) + + assert "failed to complete the prompt" in str(exc_info.value) + + +def test_get_mcp_tool_with_project_connection_id() -> None: + tool_config = FoundryChatClient.get_mcp_tool( + name="Docs MCP", + project_connection_id="conn-123", + allowed_tools=["search_docs"], + ) + + assert tool_config["project_connection_id"] == "conn-123" + assert tool_config["allowed_tools"] == ["search_docs"] + assert tool_config["server_label"] == "Docs_MCP" + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_foundry_integration_tests_disabled +@pytest.mark.parametrize( + "option_name,option_value,needs_validation", + [ + param("max_tokens", 500, False, id="max_tokens"), + param("seed", 123, False, id="seed"), + param("user", "test-user-id", False, id="user"), + param("metadata", {"test_key": "test_value"}, False, id="metadata"), + param("tool_choice", "none", True, id="tool_choice_none"), + param("tools", [get_weather], True, id="tools_function"), + param("tool_choice", "auto", True, id="tool_choice_auto"), + param("response_format", OutputStruct, True, id="response_format_pydantic"), + param( + "response_format", + { + "type": "json_schema", + "json_schema": { + "name": "WeatherDigest", + "strict": True, + "schema": { + "title": "WeatherDigest", + "type": "object", + "properties": { + "location": {"type": "string"}, + "conditions": {"type": "string"}, + }, + "required": ["location", "conditions"], + "additionalProperties": False, + }, + }, + }, + True, + id="response_format_runtime_json_schema", + ), + ], +) +@_with_foundry_debug() +async def test_integration_options( + option_name: str, + option_value: Any, + needs_validation: bool, +) -> None: + client = FoundryChatClient(credential=AzureCliCredential()) + client.function_invocation_configuration["max_iterations"] = 2 + + if option_name.startswith("tools") or option_name.startswith("tool_choice"): + messages = [Message(role="user", text="What is the weather in Seattle?")] + elif option_name.startswith("response_format"): + messages = [Message(role="user", text="The weather in Seattle is sunny")] + messages.append(Message(role="user", text="What is the weather in Seattle?")) + else: + messages = [Message(role="user", text="Say 'Hello World' briefly.")] + + options: dict[str, Any] = {option_name: option_value} + if option_name.startswith("tool_choice"): + options["tools"] = [get_weather] + + response = await client.get_response(messages=messages, options=options, stream=True).get_final_response() + + assert isinstance(response, ChatResponse) + assert response.text is not None + assert len(response.text) > 0 + + if needs_validation: + if option_name.startswith("tools") or option_name.startswith("tool_choice"): + text = response.text.lower() + assert "sunny" in text or "seattle" in text + elif option_name.startswith("response_format"): + if option_value == OutputStruct: + assert response.value is not None + assert isinstance(response.value, OutputStruct) + assert "seattle" in response.value.location.lower() + else: + assert response.value is None + response_value = json.loads(response.text) + assert isinstance(response_value, dict) + assert "location" in response_value + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_foundry_integration_tests_disabled +@_with_foundry_debug() +async def test_integration_web_search() -> None: + client = FoundryChatClient(credential=AzureCliCredential()) + + web_search_tool = FoundryChatClient.get_web_search_tool() + content = { + "messages": [ + Message( + role="user", + text="Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.", + ) + ], + "options": {"tool_choice": "auto", "tools": [web_search_tool]}, + } + response = await client.get_response(stream=True, **content).get_final_response() + + assert isinstance(response, ChatResponse) + assert "Rumi" in response.text + assert "Mira" in response.text + assert "Zoey" in response.text + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_foundry_integration_tests_disabled +@_with_foundry_debug() +async def test_integration_tool_rich_content_image() -> None: + image_path = Path(__file__).parent.parent / "assets" / "sample_image.jpg" + image_bytes = image_path.read_bytes() + + @tool(approval_mode="never_require") + def get_test_image() -> Content: + return Content.from_data(data=image_bytes, media_type="image/jpeg") + + client = FoundryChatClient(credential=AzureCliCredential()) + client.function_invocation_configuration["max_iterations"] = 2 + + messages = [Message(role="user", text="Call the get_test_image tool and describe what you see.")] + options: dict[str, Any] = {"tools": [get_test_image], "tool_choice": "auto"} + + response = await client.get_response(messages=messages, options=options, stream=True).get_final_response() + + assert isinstance(response, ChatResponse) + assert response.text is not None + assert len(response.text) > 0 + assert "house" in response.text.lower(), f"Model did not describe the house image. Response: {response.text}" + + +def test_get_code_interpreter_tool() -> None: + """Test code interpreter tool creation.""" + + tool_obj = RawFoundryChatClient.get_code_interpreter_tool() + assert tool_obj is not None + + +def test_get_code_interpreter_tool_with_file_ids() -> None: + """Test code interpreter tool with file IDs.""" + + tool_obj = RawFoundryChatClient.get_code_interpreter_tool(file_ids=["file-abc123"]) + assert tool_obj is not None + + +def test_get_file_search_tool() -> None: + """Test file search tool creation.""" + + tool_obj = RawFoundryChatClient.get_file_search_tool(vector_store_ids=["vs_abc123"]) + assert tool_obj is not None + + +def test_get_file_search_tool_requires_vector_store_ids() -> None: + """Test that empty vector_store_ids raises ValueError.""" + + with pytest.raises(ValueError, match="vector_store_ids"): + RawFoundryChatClient.get_file_search_tool(vector_store_ids=[]) + + +def test_get_web_search_tool() -> None: + """Test web search tool creation.""" + + tool_obj = RawFoundryChatClient.get_web_search_tool() + assert tool_obj is not None + + +def test_get_web_search_tool_with_location() -> None: + """Test web search tool with user location.""" + + tool_obj = RawFoundryChatClient.get_web_search_tool( + user_location={"city": "Seattle", "country": "US"}, + search_context_size="high", + ) + assert tool_obj is not None + + +def test_get_image_generation_tool() -> None: + """Test image generation tool creation.""" + + tool_obj = RawFoundryChatClient.get_image_generation_tool() + assert tool_obj is not None + + +def test_get_mcp_tool() -> None: + """Test MCP tool creation.""" + + tool_obj = RawFoundryChatClient.get_mcp_tool( + name="my_mcp", + url="https://mcp.example.com", + ) + assert tool_obj is not None + + +def test_get_mcp_tool_with_connection_id() -> None: + """Test MCP tool with project connection ID.""" + + tool_obj = RawFoundryChatClient.get_mcp_tool( + name="github_mcp", + project_connection_id="conn_abc123", + description="GitHub MCP via Foundry", + ) + assert tool_obj is not None diff --git a/python/packages/foundry/tests/foundry/test_foundry_memory_provider.py b/python/packages/foundry/tests/foundry/test_foundry_memory_provider.py new file mode 100644 index 0000000000..005eed29ce --- /dev/null +++ b/python/packages/foundry/tests/foundry/test_foundry_memory_provider.py @@ -0,0 +1,501 @@ +# Copyright (c) Microsoft. All rights reserved. +# pyright: reportPrivateUsage=false + +from __future__ import annotations + +import os +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from agent_framework import AGENT_FRAMEWORK_USER_AGENT, AgentResponse, Message +from agent_framework._sessions import AgentSession, SessionContext + +from agent_framework_foundry._memory_provider import FoundryMemoryProvider + + +@pytest.fixture +def mock_project_client() -> AsyncMock: + """Create a mock AIProjectClient.""" + mock_client = AsyncMock() + mock_client.beta = AsyncMock() + mock_client.beta.memory_stores = AsyncMock() + mock_client.beta.memory_stores.search_memories = AsyncMock() + mock_client.beta.memory_stores.begin_update_memories = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock() + return mock_client + + +@pytest.fixture +def mock_credential() -> Mock: + """Create a mock Azure credential.""" + return Mock() + + +# -- Initialization tests ------------------------------------------------------ + + +def test_init_with_all_params(mock_project_client: AsyncMock) -> None: + provider = FoundryMemoryProvider( + source_id="custom_source", + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + context_prompt="Custom prompt", + update_delay=60, + ) + assert provider.source_id == "custom_source" + assert provider.project_client is mock_project_client + assert provider.memory_store_name == "test_store" + assert provider.scope == "user_123" + assert provider.context_prompt == "Custom prompt" + assert provider.update_delay == 60 + + +def test_init_default_source_id(mock_project_client: AsyncMock) -> None: + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + assert provider.source_id == FoundryMemoryProvider.DEFAULT_SOURCE_ID + + +def test_init_default_context_prompt(mock_project_client: AsyncMock) -> None: + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + assert provider.context_prompt == FoundryMemoryProvider.DEFAULT_CONTEXT_PROMPT + + +def test_init_default_update_delay(mock_project_client: AsyncMock) -> None: + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + assert provider.update_delay == 300 + + +def test_init_with_project_endpoint_and_credential(mock_project_client: AsyncMock, mock_credential: Mock) -> None: + with patch("agent_framework_foundry._memory_provider.AIProjectClient") as mock_ai_project_client: + mock_ai_project_client.return_value = mock_project_client + provider = FoundryMemoryProvider( + project_endpoint="https://test.project.endpoint", + credential=mock_credential, # type: ignore[arg-type] + allow_preview=True, + memory_store_name="test_store", + scope="user_123", + ) + assert provider.project_client is mock_project_client + mock_ai_project_client.assert_called_once_with( + endpoint="https://test.project.endpoint", + credential=mock_credential, + allow_preview=True, + user_agent=AGENT_FRAMEWORK_USER_AGENT, + ) + + +def test_init_requires_project_endpoint_without_project_client() -> None: + with ( + patch("agent_framework_foundry._memory_provider.load_settings") as mock_load_settings, + patch.dict(os.environ, {}, clear=True), + pytest.raises(ValueError, match="project endpoint is required"), + ): + mock_load_settings.return_value = {"project_endpoint": None} + FoundryMemoryProvider( + memory_store_name="test_store", + scope="user_123", + ) + + +def test_init_requires_credential_without_project_client() -> None: + with pytest.raises(ValueError, match="Azure credential is required"): + FoundryMemoryProvider( + project_endpoint="https://test.project.endpoint", + memory_store_name="test_store", + scope="user_123", + ) + + +def test_init_requires_memory_store_name(mock_project_client: AsyncMock) -> None: + with pytest.raises(ValueError, match="memory_store_name is required"): + FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="", + scope="user_123", + ) + + +def test_init_requires_scope(mock_project_client: AsyncMock) -> None: + with pytest.raises(ValueError, match="scope is required"): + FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="", + ) + + +# -- before_run tests ---------------------------------------------------------- + + +async def test_retrieves_static_memories_on_first_run(mock_project_client: AsyncMock) -> None: + mem1 = Mock() + mem1.memory_item.content = "User prefers Python" + mem2 = Mock() + mem2.memory_item.content = "User is based in Seattle" + mock_search_result = Mock() + mock_search_result.memories = [mem1, mem2] + mock_project_client.beta.memory_stores.search_memories.return_value = mock_search_result + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") + + await provider.before_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + + # Should call search_memories twice: once for static, once for contextual + assert mock_project_client.beta.memory_stores.search_memories.call_count == 2 + # Static memories should be cached + assert len(session.state[provider.source_id]["static_memories"]) == 2 + assert session.state[provider.source_id]["initialized"] is True + + +async def test_contextual_memories_added_to_context(mock_project_client: AsyncMock) -> None: + # Mock static search (first call) + static_mem = Mock() + static_mem.memory_item.content = "User prefers Python" + static_result = Mock() + static_result.memories = [static_mem] + + # Mock contextual search (second call) + contextual_mem = Mock() + contextual_mem.memory_item.content = "Last discussed async patterns" + contextual_result = Mock() + contextual_result.memories = [contextual_mem] + contextual_result.search_id = "search-123" + + mock_project_client.beta.memory_stores.search_memories.side_effect = [static_result, contextual_result] + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") + + await provider.before_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + + # Check that memories were added to context + assert provider.source_id in ctx.context_messages + added = ctx.context_messages[provider.source_id] + assert len(added) == 1 + assert "User prefers Python" in added[0].text # type: ignore[operator] + assert "Last discussed async patterns" in added[0].text # type: ignore[operator] + assert provider.context_prompt in added[0].text # type: ignore[operator] + assert session.state[provider.source_id]["previous_search_id"] == "search-123" + + +async def test_empty_input_skips_contextual_search(mock_project_client: AsyncMock) -> None: + static_result = Mock() + static_result.memories = [] + mock_project_client.beta.memory_stores.search_memories.return_value = static_result + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="")], session_id="s1") + + await provider.before_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + + # Should only call search_memories once for static memories + assert mock_project_client.beta.memory_stores.search_memories.call_count == 1 + assert provider.source_id not in ctx.context_messages + + +async def test_empty_search_results_no_messages(mock_project_client: AsyncMock) -> None: + mock_search_result = Mock() + mock_search_result.memories = [] + mock_project_client.beta.memory_stores.search_memories.return_value = mock_search_result + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="test")], session_id="s1") + + await provider.before_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + + assert provider.source_id not in ctx.context_messages + + +async def test_static_memories_only_retrieved_once(mock_project_client: AsyncMock) -> None: + static_mem = Mock() + static_mem.memory_item.content = "Static memory" + static_result = Mock() + static_result.memories = [static_mem] + contextual_result = Mock() + contextual_result.memories = [] + + mock_project_client.beta.memory_stores.search_memories.side_effect = [static_result, contextual_result] + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") + + # First call + await provider.before_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + assert mock_project_client.beta.memory_stores.search_memories.call_count == 2 + + # Reset mock for second call + mock_project_client.beta.memory_stores.search_memories.reset_mock() + contextual_result2 = Mock() + contextual_result2.memories = [] + mock_project_client.beta.memory_stores.search_memories.return_value = contextual_result2 + + # Second call - should only search contextual, not static + ctx2 = SessionContext(input_messages=[Message(role="user", text="World")], session_id="s1") + await provider.before_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx2, state=session.state.setdefault(provider.source_id, {}) + ) + assert mock_project_client.beta.memory_stores.search_memories.call_count == 1 + + +async def test_handles_search_exception_gracefully(mock_project_client: AsyncMock) -> None: + mock_project_client.beta.memory_stores.search_memories.side_effect = Exception("API error") + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") + + # Should not raise exception + await provider.before_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + + # No memories added + assert provider.source_id not in ctx.context_messages + + +# -- after_run tests ----------------------------------------------------------- + + +async def test_stores_input_and_response(mock_project_client: AsyncMock) -> None: + mock_poller = Mock() + mock_poller.update_id = "update-456" + mock_project_client.beta.memory_stores.begin_update_memories.return_value = mock_poller + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="question")], session_id="s1") + ctx._response = AgentResponse(messages=[Message(role="assistant", text="answer")]) + + await provider.after_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + + mock_project_client.beta.memory_stores.begin_update_memories.assert_awaited_once() + call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs + assert call_kwargs["name"] == "test_store" + assert call_kwargs["scope"] == "user_123" + assert len(call_kwargs["items"]) == 2 + assert call_kwargs["items"][0]["content"] == "question" + assert call_kwargs["items"][1]["content"] == "answer" + assert session.state[provider.source_id]["previous_update_id"] == "update-456" + + +async def test_only_stores_user_assistant_system(mock_project_client: AsyncMock) -> None: + mock_poller = Mock() + mock_project_client.beta.memory_stores.begin_update_memories.return_value = mock_poller + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext( + input_messages=[ + Message(role="user", text="hello"), + Message(role="tool", text="tool output"), + ], + session_id="s1", + ) + ctx._response = AgentResponse(messages=[Message(role="assistant", text="reply")]) + + await provider.after_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + + call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs + items = call_kwargs["items"] + assert len(items) == 2 + assert items[0]["content"] == "hello" + assert items[1]["content"] == "reply" + + +async def test_skips_empty_messages(mock_project_client: AsyncMock) -> None: + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext( + input_messages=[ + Message(role="user", text=""), + Message(role="user", text=" "), + ], + session_id="s1", + ) + ctx._response = AgentResponse(messages=[]) + + await provider.after_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + + mock_project_client.beta.memory_stores.begin_update_memories.assert_not_awaited() + + +async def test_uses_configured_update_delay(mock_project_client: AsyncMock) -> None: + mock_poller = Mock() + mock_project_client.beta.memory_stores.begin_update_memories.return_value = mock_poller + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + update_delay=60, + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="hi")], session_id="s1") + ctx._response = AgentResponse(messages=[Message(role="assistant", text="hey")]) + + await provider.after_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + + call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs + assert call_kwargs["update_delay"] == 60 + + +async def test_uses_previous_update_id_for_incremental_updates(mock_project_client: AsyncMock) -> None: + mock_poller1 = Mock() + mock_poller1.update_id = "update-1" + mock_poller2 = Mock() + mock_poller2.update_id = "update-2" + + mock_project_client.beta.memory_stores.begin_update_memories.side_effect = [mock_poller1, mock_poller2] + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx1 = SessionContext(input_messages=[Message(role="user", text="first")], session_id="s1") + ctx1._response = AgentResponse(messages=[Message(role="assistant", text="response1")]) + + # First update + await provider.after_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx1, state=session.state.setdefault(provider.source_id, {}) + ) + assert session.state[provider.source_id]["previous_update_id"] == "update-1" + + # Second update should use previous_update_id + ctx2 = SessionContext(input_messages=[Message(role="user", text="second")], session_id="s1") + ctx2._response = AgentResponse(messages=[Message(role="assistant", text="response2")]) + + await provider.after_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx2, state=session.state.setdefault(provider.source_id, {}) + ) + + call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs + assert call_kwargs["previous_update_id"] == "update-1" + assert session.state[provider.source_id]["previous_update_id"] == "update-2" + + +async def test_handles_update_exception_gracefully(mock_project_client: AsyncMock) -> None: + mock_project_client.beta.memory_stores.begin_update_memories.side_effect = Exception("API error") + + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + session = AgentSession(session_id="test-session") + ctx = SessionContext(input_messages=[Message(role="user", text="hi")], session_id="s1") + ctx._response = AgentResponse(messages=[Message(role="assistant", text="hey")]) + + # Should not raise exception + await provider.after_run( # type: ignore[arg-type] + agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) + ) + + +# -- Context manager tests ----------------------------------------------------- + + +async def test_aenter_delegates_to_client(mock_project_client: AsyncMock) -> None: + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + result = await provider.__aenter__() + assert result is provider + mock_project_client.__aenter__.assert_awaited_once() + + +async def test_aexit_delegates_to_client(mock_project_client: AsyncMock) -> None: + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + await provider.__aexit__(None, None, None) + mock_project_client.__aexit__.assert_awaited_once() + + +async def test_async_with_syntax(mock_project_client: AsyncMock) -> None: + provider = FoundryMemoryProvider( + project_client=mock_project_client, + memory_store_name="test_store", + scope="user_123", + ) + async with provider as p: + assert p is provider diff --git a/python/packages/foundry/tests/test_foundry_agent.py b/python/packages/foundry/tests/test_foundry_agent.py deleted file mode 100644 index 549d922ff9..0000000000 --- a/python/packages/foundry/tests/test_foundry_agent.py +++ /dev/null @@ -1,374 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Tests for FoundryAgentClient and FoundryAgent classes.""" - -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from agent_framework._tools import tool - - -class TestRawFoundryAgentChatClient: - """Tests for RawFoundryAgentChatClient.""" - - def test_init_requires_agent_name(self) -> None: - """Test that agent_name is required.""" - from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient - - with pytest.raises(ValueError, match="Agent name is required"): - RawFoundryAgentChatClient( - project_client=MagicMock(), - ) - - def test_init_with_agent_name(self) -> None: - """Test construction with agent_name and project_client.""" - from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - client = RawFoundryAgentChatClient( - project_client=mock_project, - agent_name="test-agent", - agent_version="1.0", - ) - - assert client.agent_name == "test-agent" - assert client.agent_version == "1.0" - - def test_get_agent_reference_with_version(self) -> None: - """Test agent reference includes version when provided.""" - from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - client = RawFoundryAgentChatClient( - project_client=mock_project, - agent_name="my-agent", - agent_version="2.0", - ) - - ref = client._get_agent_reference() - assert ref == {"name": "my-agent", "version": "2.0", "type": "agent_reference"} - - def test_get_agent_reference_without_version(self) -> None: - """Test agent reference omits version for HostedAgents.""" - from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - client = RawFoundryAgentChatClient( - project_client=mock_project, - agent_name="hosted-agent", - ) - - ref = client._get_agent_reference() - assert ref == {"name": "hosted-agent", "type": "agent_reference"} - assert "version" not in ref - - def test_as_agent_returns_foundry_agent_and_preserves_client_type(self) -> None: - """Test that as_agent() wraps the client in FoundryAgent using the same client class.""" - from agent_framework_foundry._foundry_agent import FoundryAgent - from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient - - class CustomClient(RawFoundryAgentChatClient): - pass - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - client = CustomClient( - project_client=mock_project, - agent_name="test-agent", - agent_version="1.0", - ) - - agent = client.as_agent(instructions="You are helpful.") - - assert isinstance(agent, FoundryAgent) - assert agent.name == "test-agent" - assert isinstance(agent.client, CustomClient) - assert agent.client.project_client is mock_project - assert agent.client.agent_name == "test-agent" - assert agent.client.agent_version == "1.0" - - named_agent = client.as_agent(name="display-name", instructions="You are helpful.") - assert named_agent.name == "display-name" - assert named_agent.client.agent_name == "test-agent" - - async def test_prepare_options_validates_tools(self) -> None: - """Test that _prepare_options rejects non-FunctionTool objects.""" - from agent_framework import Message - - from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - client = RawFoundryAgentChatClient( - project_client=mock_project, - agent_name="test-agent", - ) - - # A dict tool should be rejected - with pytest.raises(TypeError, match="Only FunctionTool objects are accepted"): - await client._prepare_options( - messages=[Message(role="user", contents="hi")], - options={"tools": [{"type": "function", "function": {"name": "bad"}}]}, - ) - - async def test_prepare_options_accepts_function_tools(self) -> None: - """Test that _prepare_options accepts FunctionTool objects.""" - from agent_framework import Message - - from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient - - mock_project = MagicMock() - mock_openai = MagicMock() - mock_project.get_openai_client.return_value = mock_openai - - client = RawFoundryAgentChatClient( - project_client=mock_project, - agent_name="test-agent", - ) - - @tool(approval_mode="never_require") - def my_func() -> str: - """A test function.""" - return "ok" - - # Should not raise — patch the parent's _prepare_options - with patch( - "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", - new_callable=AsyncMock, - return_value={}, - ): - result = await client._prepare_options( - messages=[Message(role="user", contents="hi")], - options={"tools": [my_func]}, - ) - assert "extra_body" in result - assert result["extra_body"]["agent_reference"]["name"] == "test-agent" - - def test_check_model_presence_is_noop(self) -> None: - """Test that _check_model_presence does nothing (model is on service).""" - from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - client = RawFoundryAgentChatClient( - project_client=mock_project, - agent_name="test-agent", - ) - - options: dict[str, Any] = {} - client._check_model_presence(options) - assert "model" not in options - - -class TestFoundryAgentChatClient: - """Tests for _FoundryAgentChatClient (full middleware).""" - - def test_init(self) -> None: - """Test construction of the full-middleware client.""" - from agent_framework_foundry._foundry_agent_client import _FoundryAgentChatClient - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - client = _FoundryAgentChatClient( - project_client=mock_project, - agent_name="test-agent", - agent_version="1.0", - ) - - assert client.agent_name == "test-agent" - - -class TestRawFoundryAgent: - """Tests for RawFoundryAgent.""" - - def test_init_creates_client(self) -> None: - """Test that RawFoundryAgent creates a client internally.""" - from agent_framework_foundry._foundry_agent import RawFoundryAgent - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - agent = RawFoundryAgent( - project_client=mock_project, - agent_name="test-agent", - agent_version="1.0", - ) - - assert agent.client is not None - assert agent.client.agent_name == "test-agent" - - def test_init_with_custom_client_type(self) -> None: - """Test that client_type parameter is respected.""" - from agent_framework_foundry._foundry_agent import RawFoundryAgent - from agent_framework_foundry._foundry_agent_client import RawFoundryAgentChatClient - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - agent = RawFoundryAgent( - project_client=mock_project, - agent_name="test-agent", - client_type=RawFoundryAgentChatClient, - ) - - assert isinstance(agent.client, RawFoundryAgentChatClient) - - def test_init_rejects_invalid_client_type(self) -> None: - """Test that invalid client_type raises TypeError.""" - from agent_framework_foundry._foundry_agent import RawFoundryAgent - - with pytest.raises(TypeError, match="must be a subclass of RawFoundryAgentChatClient"): - RawFoundryAgent( - project_client=MagicMock(), - agent_name="test-agent", - client_type=object, # type: ignore[arg-type] - ) - - def test_init_with_function_tools(self) -> None: - """Test that FunctionTool and callables are accepted.""" - from agent_framework_foundry._foundry_agent import RawFoundryAgent - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - @tool(approval_mode="never_require") - def my_func() -> str: - """A test function.""" - return "ok" - - agent = RawFoundryAgent( - project_client=mock_project, - agent_name="test-agent", - tools=[my_func], - ) - - assert agent.default_options.get("tools") is not None - - -class TestFoundryAgent: - """Tests for FoundryAgent (full middleware).""" - - def test_init(self) -> None: - """Test construction of the full-middleware agent.""" - from agent_framework_foundry._foundry_agent import FoundryAgent - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - agent = FoundryAgent( - project_client=mock_project, - agent_name="test-agent", - agent_version="1.0", - ) - - assert agent.client is not None - assert agent.client.agent_name == "test-agent" - - def test_init_with_middleware(self) -> None: - """Test that agent-level middleware is accepted.""" - from agent_framework import ChatContext, ChatMiddleware - - from agent_framework_foundry._foundry_agent import FoundryAgent - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - class MyMiddleware(ChatMiddleware): - async def process(self, context: ChatContext) -> None: - pass - - agent = FoundryAgent( - project_client=mock_project, - agent_name="test-agent", - middleware=[MyMiddleware()], - ) - - assert agent.client is not None - - -class TestFoundryChatClientToolMethods: - """Tests for RawFoundryChatClient tool factory methods.""" - - def test_get_code_interpreter_tool(self) -> None: - """Test code interpreter tool creation.""" - from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient - - tool_obj = RawFoundryChatClient.get_code_interpreter_tool() - assert tool_obj is not None - - def test_get_code_interpreter_tool_with_file_ids(self) -> None: - """Test code interpreter tool with file IDs.""" - from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient - - tool_obj = RawFoundryChatClient.get_code_interpreter_tool(file_ids=["file-abc123"]) - assert tool_obj is not None - - def test_get_file_search_tool(self) -> None: - """Test file search tool creation.""" - from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient - - tool_obj = RawFoundryChatClient.get_file_search_tool(vector_store_ids=["vs_abc123"]) - assert tool_obj is not None - - def test_get_file_search_tool_requires_vector_store_ids(self) -> None: - """Test that empty vector_store_ids raises ValueError.""" - from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient - - with pytest.raises(ValueError, match="vector_store_ids"): - RawFoundryChatClient.get_file_search_tool(vector_store_ids=[]) - - def test_get_web_search_tool(self) -> None: - """Test web search tool creation.""" - from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient - - tool_obj = RawFoundryChatClient.get_web_search_tool() - assert tool_obj is not None - - def test_get_web_search_tool_with_location(self) -> None: - """Test web search tool with user location.""" - from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient - - tool_obj = RawFoundryChatClient.get_web_search_tool( - user_location={"city": "Seattle", "country": "US"}, - search_context_size="high", - ) - assert tool_obj is not None - - def test_get_image_generation_tool(self) -> None: - """Test image generation tool creation.""" - from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient - - tool_obj = RawFoundryChatClient.get_image_generation_tool() - assert tool_obj is not None - - def test_get_mcp_tool(self) -> None: - """Test MCP tool creation.""" - from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient - - tool_obj = RawFoundryChatClient.get_mcp_tool( - name="my_mcp", - url="https://mcp.example.com", - ) - assert tool_obj is not None - - def test_get_mcp_tool_with_connection_id(self) -> None: - """Test MCP tool with project connection ID.""" - from agent_framework_foundry._foundry_chat_client import RawFoundryChatClient - - tool_obj = RawFoundryChatClient.get_mcp_tool( - name="github_mcp", - project_connection_id="conn_abc123", - description="GitHub MCP via Foundry", - ) - assert tool_obj is not None diff --git a/python/packages/foundry/tests/test_foundry_memory_provider.py b/python/packages/foundry/tests/test_foundry_memory_provider.py deleted file mode 100644 index f7e02f8a89..0000000000 --- a/python/packages/foundry/tests/test_foundry_memory_provider.py +++ /dev/null @@ -1,507 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -# pyright: reportPrivateUsage=false - -from __future__ import annotations - -import os -from unittest.mock import AsyncMock, Mock, patch - -import pytest -from agent_framework import AGENT_FRAMEWORK_USER_AGENT, AgentResponse, Message -from agent_framework._sessions import AgentSession, SessionContext - -from agent_framework_foundry._foundry_memory_provider import FoundryMemoryProvider - - -@pytest.fixture -def mock_project_client() -> AsyncMock: - """Create a mock AIProjectClient.""" - mock_client = AsyncMock() - mock_client.beta = AsyncMock() - mock_client.beta.memory_stores = AsyncMock() - mock_client.beta.memory_stores.search_memories = AsyncMock() - mock_client.beta.memory_stores.begin_update_memories = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock() - return mock_client - - -@pytest.fixture -def mock_credential() -> Mock: - """Create a mock Azure credential.""" - return Mock() - - -# -- Initialization tests ------------------------------------------------------ - - -class TestInit: - """Test FoundryMemoryProvider initialization.""" - - def test_init_with_all_params(self, mock_project_client: AsyncMock) -> None: - provider = FoundryMemoryProvider( - source_id="custom_source", - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - context_prompt="Custom prompt", - update_delay=60, - ) - assert provider.source_id == "custom_source" - assert provider.project_client is mock_project_client - assert provider.memory_store_name == "test_store" - assert provider.scope == "user_123" - assert provider.context_prompt == "Custom prompt" - assert provider.update_delay == 60 - - def test_init_default_source_id(self, mock_project_client: AsyncMock) -> None: - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - assert provider.source_id == FoundryMemoryProvider.DEFAULT_SOURCE_ID - - def test_init_default_context_prompt(self, mock_project_client: AsyncMock) -> None: - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - assert provider.context_prompt == FoundryMemoryProvider.DEFAULT_CONTEXT_PROMPT - - def test_init_default_update_delay(self, mock_project_client: AsyncMock) -> None: - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - assert provider.update_delay == 300 - - def test_init_with_project_endpoint_and_credential( - self, mock_project_client: AsyncMock, mock_credential: Mock - ) -> None: - with patch("agent_framework_foundry._foundry_memory_provider.AIProjectClient") as mock_ai_project_client: - mock_ai_project_client.return_value = mock_project_client - provider = FoundryMemoryProvider( - project_endpoint="https://test.project.endpoint", - credential=mock_credential, # type: ignore[arg-type] - allow_preview=True, - memory_store_name="test_store", - scope="user_123", - ) - assert provider.project_client is mock_project_client - mock_ai_project_client.assert_called_once_with( - endpoint="https://test.project.endpoint", - credential=mock_credential, - allow_preview=True, - user_agent=AGENT_FRAMEWORK_USER_AGENT, - ) - - def test_init_requires_project_endpoint_without_project_client(self) -> None: - with ( - patch("agent_framework_foundry._foundry_memory_provider.load_settings") as mock_load_settings, - patch.dict(os.environ, {}, clear=True), - pytest.raises(ValueError, match="project endpoint is required"), - ): - mock_load_settings.return_value = {"project_endpoint": None} - FoundryMemoryProvider( - memory_store_name="test_store", - scope="user_123", - ) - - def test_init_requires_credential_without_project_client(self) -> None: - with pytest.raises(ValueError, match="Azure credential is required"): - FoundryMemoryProvider( - project_endpoint="https://test.project.endpoint", - memory_store_name="test_store", - scope="user_123", - ) - - def test_init_requires_memory_store_name(self, mock_project_client: AsyncMock) -> None: - with pytest.raises(ValueError, match="memory_store_name is required"): - FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="", - scope="user_123", - ) - - def test_init_requires_scope(self, mock_project_client: AsyncMock) -> None: - with pytest.raises(ValueError, match="scope is required"): - FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="", - ) - - -# -- before_run tests ---------------------------------------------------------- - - -class TestBeforeRun: - """Test before_run hook.""" - - async def test_retrieves_static_memories_on_first_run(self, mock_project_client: AsyncMock) -> None: - """First call retrieves static (user profile) memories.""" - mem1 = Mock() - mem1.memory_item.content = "User prefers Python" - mem2 = Mock() - mem2.memory_item.content = "User is based in Seattle" - mock_search_result = Mock() - mock_search_result.memories = [mem1, mem2] - mock_project_client.beta.memory_stores.search_memories.return_value = mock_search_result - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") - - await provider.before_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - - # Should call search_memories twice: once for static, once for contextual - assert mock_project_client.beta.memory_stores.search_memories.call_count == 2 - # Static memories should be cached - assert len(session.state[provider.source_id]["static_memories"]) == 2 - assert session.state[provider.source_id]["initialized"] is True - - async def test_contextual_memories_added_to_context(self, mock_project_client: AsyncMock) -> None: - """Contextual search returns memories → messages added to context with prompt.""" - # Mock static search (first call) - static_mem = Mock() - static_mem.memory_item.content = "User prefers Python" - static_result = Mock() - static_result.memories = [static_mem] - - # Mock contextual search (second call) - contextual_mem = Mock() - contextual_mem.memory_item.content = "Last discussed async patterns" - contextual_result = Mock() - contextual_result.memories = [contextual_mem] - contextual_result.search_id = "search-123" - - mock_project_client.beta.memory_stores.search_memories.side_effect = [static_result, contextual_result] - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") - - await provider.before_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - - # Check that memories were added to context - assert provider.source_id in ctx.context_messages - added = ctx.context_messages[provider.source_id] - assert len(added) == 1 - assert "User prefers Python" in added[0].text # type: ignore[operator] - assert "Last discussed async patterns" in added[0].text # type: ignore[operator] - assert provider.context_prompt in added[0].text # type: ignore[operator] - assert session.state[provider.source_id]["previous_search_id"] == "search-123" - - async def test_empty_input_skips_contextual_search(self, mock_project_client: AsyncMock) -> None: - """Empty input messages → only static search performed, no contextual search.""" - static_result = Mock() - static_result.memories = [] - mock_project_client.beta.memory_stores.search_memories.return_value = static_result - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="")], session_id="s1") - - await provider.before_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - - # Should only call search_memories once for static memories - assert mock_project_client.beta.memory_stores.search_memories.call_count == 1 - assert provider.source_id not in ctx.context_messages - - async def test_empty_search_results_no_messages(self, mock_project_client: AsyncMock) -> None: - """Empty search results → no messages added.""" - mock_search_result = Mock() - mock_search_result.memories = [] - mock_project_client.beta.memory_stores.search_memories.return_value = mock_search_result - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="test")], session_id="s1") - - await provider.before_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - - assert provider.source_id not in ctx.context_messages - - async def test_static_memories_only_retrieved_once(self, mock_project_client: AsyncMock) -> None: - """Static memories are only retrieved on the first call.""" - static_mem = Mock() - static_mem.memory_item.content = "Static memory" - static_result = Mock() - static_result.memories = [static_mem] - contextual_result = Mock() - contextual_result.memories = [] - - mock_project_client.beta.memory_stores.search_memories.side_effect = [static_result, contextual_result] - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") - - # First call - await provider.before_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - assert mock_project_client.beta.memory_stores.search_memories.call_count == 2 - - # Reset mock for second call - mock_project_client.beta.memory_stores.search_memories.reset_mock() - contextual_result2 = Mock() - contextual_result2.memories = [] - mock_project_client.beta.memory_stores.search_memories.return_value = contextual_result2 - - # Second call - should only search contextual, not static - ctx2 = SessionContext(input_messages=[Message(role="user", text="World")], session_id="s1") - await provider.before_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx2, state=session.state.setdefault(provider.source_id, {}) - ) - assert mock_project_client.beta.memory_stores.search_memories.call_count == 1 - - async def test_handles_search_exception_gracefully(self, mock_project_client: AsyncMock) -> None: - """Search exception is logged but doesn't fail the operation.""" - mock_project_client.beta.memory_stores.search_memories.side_effect = Exception("API error") - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="Hello")], session_id="s1") - - # Should not raise exception - await provider.before_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - - # No memories added - assert provider.source_id not in ctx.context_messages - - -# -- after_run tests ----------------------------------------------------------- - - -class TestAfterRun: - """Test after_run hook.""" - - async def test_stores_input_and_response(self, mock_project_client: AsyncMock) -> None: - """Stores input+response messages via begin_update_memories.""" - mock_poller = Mock() - mock_poller.update_id = "update-456" - mock_project_client.beta.memory_stores.begin_update_memories.return_value = mock_poller - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="question")], session_id="s1") - ctx._response = AgentResponse(messages=[Message(role="assistant", text="answer")]) - - await provider.after_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - - mock_project_client.beta.memory_stores.begin_update_memories.assert_awaited_once() - call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs - assert call_kwargs["name"] == "test_store" - assert call_kwargs["scope"] == "user_123" - assert len(call_kwargs["items"]) == 2 - assert call_kwargs["items"][0]["content"] == "question" - assert call_kwargs["items"][1]["content"] == "answer" - assert session.state[provider.source_id]["previous_update_id"] == "update-456" - - async def test_only_stores_user_assistant_system(self, mock_project_client: AsyncMock) -> None: - """Only stores user/assistant/system messages with text.""" - mock_poller = Mock() - mock_project_client.beta.memory_stores.begin_update_memories.return_value = mock_poller - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext( - input_messages=[ - Message(role="user", text="hello"), - Message(role="tool", text="tool output"), - ], - session_id="s1", - ) - ctx._response = AgentResponse(messages=[Message(role="assistant", text="reply")]) - - await provider.after_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - - call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs - items = call_kwargs["items"] - assert len(items) == 2 - assert items[0]["content"] == "hello" - assert items[1]["content"] == "reply" - - async def test_skips_empty_messages(self, mock_project_client: AsyncMock) -> None: - """Skips messages with empty text.""" - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext( - input_messages=[ - Message(role="user", text=""), - Message(role="user", text=" "), - ], - session_id="s1", - ) - ctx._response = AgentResponse(messages=[]) - - await provider.after_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - - mock_project_client.beta.memory_stores.begin_update_memories.assert_not_awaited() - - async def test_uses_configured_update_delay(self, mock_project_client: AsyncMock) -> None: - """Uses the configured update_delay parameter.""" - mock_poller = Mock() - mock_project_client.beta.memory_stores.begin_update_memories.return_value = mock_poller - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - update_delay=60, - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="hi")], session_id="s1") - ctx._response = AgentResponse(messages=[Message(role="assistant", text="hey")]) - - await provider.after_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - - call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs - assert call_kwargs["update_delay"] == 60 - - async def test_uses_previous_update_id_for_incremental_updates(self, mock_project_client: AsyncMock) -> None: - """Uses previous_update_id for incremental updates.""" - mock_poller1 = Mock() - mock_poller1.update_id = "update-1" - mock_poller2 = Mock() - mock_poller2.update_id = "update-2" - - mock_project_client.beta.memory_stores.begin_update_memories.side_effect = [mock_poller1, mock_poller2] - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx1 = SessionContext(input_messages=[Message(role="user", text="first")], session_id="s1") - ctx1._response = AgentResponse(messages=[Message(role="assistant", text="response1")]) - - # First update - await provider.after_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx1, state=session.state.setdefault(provider.source_id, {}) - ) - assert session.state[provider.source_id]["previous_update_id"] == "update-1" - - # Second update should use previous_update_id - ctx2 = SessionContext(input_messages=[Message(role="user", text="second")], session_id="s1") - ctx2._response = AgentResponse(messages=[Message(role="assistant", text="response2")]) - - await provider.after_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx2, state=session.state.setdefault(provider.source_id, {}) - ) - - call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs - assert call_kwargs["previous_update_id"] == "update-1" - assert session.state[provider.source_id]["previous_update_id"] == "update-2" - - async def test_handles_update_exception_gracefully(self, mock_project_client: AsyncMock) -> None: - """Update exception is logged but doesn't fail the operation.""" - mock_project_client.beta.memory_stores.begin_update_memories.side_effect = Exception("API error") - - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - session = AgentSession(session_id="test-session") - ctx = SessionContext(input_messages=[Message(role="user", text="hi")], session_id="s1") - ctx._response = AgentResponse(messages=[Message(role="assistant", text="hey")]) - - # Should not raise exception - await provider.after_run( # type: ignore[arg-type] - agent=None, session=session, context=ctx, state=session.state.setdefault(provider.source_id, {}) - ) - - -# -- Context manager tests ----------------------------------------------------- - - -class TestContextManager: - """Test __aenter__/__aexit__ delegation.""" - - async def test_aenter_delegates_to_client(self, mock_project_client: AsyncMock) -> None: - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - result = await provider.__aenter__() - assert result is provider - mock_project_client.__aenter__.assert_awaited_once() - - async def test_aexit_delegates_to_client(self, mock_project_client: AsyncMock) -> None: - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - await provider.__aexit__(None, None, None) - mock_project_client.__aexit__.assert_awaited_once() - - async def test_async_with_syntax(self, mock_project_client: AsyncMock) -> None: - provider = FoundryMemoryProvider( - project_client=mock_project_client, - memory_store_name="test_store", - scope="user_123", - ) - async with provider as p: - assert p is provider diff --git a/python/packages/lab/gaia/samples/openai_agent.py b/python/packages/lab/gaia/samples/openai_agent.py index a5709ecf2a..227b12c03c 100644 --- a/python/packages/lab/gaia/samples/openai_agent.py +++ b/python/packages/lab/gaia/samples/openai_agent.py @@ -7,7 +7,7 @@ configured for GAIA benchmark tasks using the OpenAI Responses API. Required Environment Variables: OPENAI_API_KEY: Your OpenAI API key - OPENAI_RESPONSES_MODEL_ID: Model to use with Responses API (e.g., gpt-4o, gpt-4o-mini) + OPENAI_RESPONSES_MODEL: Model to use with Responses API (e.g., gpt-4o, gpt-4o-mini) Optional Environment Variables: OPENAI_BASE_URL: Custom API base URL if using a proxy or compatible service @@ -19,7 +19,7 @@ Authentication: Example: export OPENAI_API_KEY="sk-..." - export OPENAI_RESPONSES_MODEL_ID="gpt-4o" + export OPENAI_RESPONSES_MODEL="gpt-4o" """ from collections.abc import AsyncIterator diff --git a/python/packages/openai/AGENTS.md b/python/packages/openai/AGENTS.md index d31506cf5d..48c3a306bd 100644 --- a/python/packages/openai/AGENTS.md +++ b/python/packages/openai/AGENTS.md @@ -27,6 +27,10 @@ agent_framework_openai/ All clients follow the Raw + Full-Featured pattern (e.g., `RawOpenAIChatClient` + `OpenAIChatClient`). +The generic OpenAI clients support both OpenAI and Azure OpenAI routing. Precedence is: +explicit Azure inputs (`credential`, `azure_endpoint`, `api_version`) → OpenAI API key +(`OPENAI_API_KEY`) → Azure environment fallback (`AZURE_OPENAI_*`). + ## Dependencies - `agent-framework-core` — core abstractions diff --git a/python/packages/openai/README.md b/python/packages/openai/README.md index 6ed4d20c03..e04a1f947a 100644 --- a/python/packages/openai/README.md +++ b/python/packages/openai/README.md @@ -1,17 +1,106 @@ # agent-framework-openai -OpenAI integration for Microsoft Agent Framework. Provides chat clients for the OpenAI Responses API and Chat Completions API. +OpenAI integration for Microsoft Agent Framework. + +This package provides: + +- `OpenAIChatClient` for the OpenAI Responses API +- `OpenAIChatCompletionClient` for the Chat Completions API +- `OpenAIEmbeddingClient` for embeddings ## Installation ```bash -pip install agent-framework-openai +pip install agent-framework-openai --pre ``` -## Usage +## Which chat client should I use? + +Use `OpenAIChatClient` for new work unless you specifically need the Chat Completions API. + +- `OpenAIChatClient` uses the Responses API and is the preferred general-purpose chat client. +- `OpenAIChatCompletionClient` uses the Chat Completions API and is mainly for compatibility with + existing Chat Completions-based integrations. + +The deprecated `OpenAIResponsesClient` alias points to `OpenAIChatClient`. + +## Environment variables + +### OpenAI + +These variables are used when the client is configured for OpenAI: + +| Variable | Purpose | +| --- | --- | +| `OPENAI_API_KEY` | OpenAI API key | +| `OPENAI_ORG_ID` | OpenAI organization ID | +| `OPENAI_BASE_URL` | Custom OpenAI-compatible base URL | +| `OPENAI_MODEL` | Generic fallback model | +| `OPENAI_RESPONSES_MODEL` | Preferred model for `OpenAIChatClient` | +| `OPENAI_CHAT_MODEL` | Preferred model for `OpenAIChatCompletionClient` | +| `OPENAI_EMBEDDING_MODEL` | Preferred model for `OpenAIEmbeddingClient` | + +Model lookup order: + +- `OpenAIChatClient`: `OPENAI_RESPONSES_MODEL` -> `OPENAI_MODEL` +- `OpenAIChatCompletionClient`: `OPENAI_CHAT_MODEL` -> `OPENAI_MODEL` +- `OpenAIEmbeddingClient`: `OPENAI_EMBEDDING_MODEL` -> `OPENAI_MODEL` + +### Azure OpenAI + +These variables are used when the client is configured for Azure OpenAI: + +| Variable | Purpose | +| --- | --- | +| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI resource endpoint | +| `AZURE_OPENAI_BASE_URL` | Full Azure OpenAI base URL (`.../openai/v1`) | +| `AZURE_OPENAI_API_KEY` | Azure OpenAI API key | +| `AZURE_OPENAI_API_VERSION` | Azure OpenAI API version | +| `AZURE_OPENAI_DEPLOYMENT_NAME` | Generic fallback deployment | +| `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME` | Preferred deployment for `OpenAIChatClient` | +| `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME` | Preferred deployment for `OpenAIChatCompletionClient` | +| `AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME` | Preferred deployment for `OpenAIEmbeddingClient` | + +Deployment lookup order: + +- `OpenAIChatClient`: `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME` -> `AZURE_OPENAI_DEPLOYMENT_NAME` +- `OpenAIChatCompletionClient`: `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME` -> `AZURE_OPENAI_DEPLOYMENT_NAME` +- `OpenAIEmbeddingClient`: `AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME` -> `AZURE_OPENAI_DEPLOYMENT_NAME` + +When both OpenAI and Azure environment variables are present, the generic clients prefer OpenAI +when `OPENAI_API_KEY` is configured. To use Azure explicitly, pass `azure_endpoint` or +`credential`. + +## OpenAI example ```python from agent_framework.openai import OpenAIChatClient -client = OpenAIChatClient(model_id="gpt-4o") +client = OpenAIChatClient(model="gpt-4.1") +``` + +## Azure OpenAI example + +```python +from azure.identity.aio import AzureCliCredential + +from agent_framework.openai import OpenAIChatClient + +client = OpenAIChatClient( + model="my-responses-deployment", + azure_endpoint="https://my-resource.openai.azure.com", + credential=AzureCliCredential(), +) +``` + +## ChatClient vs ChatCompletionClient + +Use `OpenAIChatClient` when you want the Responses API as your default chat surface. + +Use `OpenAIChatCompletionClient` when you specifically need the Chat Completions API: + +```python +from agent_framework.openai import OpenAIChatCompletionClient + +client = OpenAIChatCompletionClient(model="gpt-4o-mini") ``` diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 86af86895e..b0d56ee26f 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -14,7 +14,6 @@ from collections.abc import ( MutableMapping, Sequence, ) -from copy import copy from datetime import datetime, timezone from itertools import chain from typing import ( @@ -28,12 +27,11 @@ from typing import ( cast, overload, ) -from urllib.parse import urljoin, urlparse from agent_framework._clients import BaseChatClient -from agent_framework._middleware import ChatMiddlewareLayer +from agent_framework._middleware import ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer from agent_framework._settings import SecretString -from agent_framework._telemetry import APP_INFO, USER_AGENT_KEY, prepend_agent_framework_to_user_agent +from agent_framework._telemetry import USER_AGENT_KEY from agent_framework._tools import ( SHELL_TOOL_KIND_VALUE, FunctionInvocationConfiguration, @@ -87,8 +85,7 @@ from pydantic import BaseModel from ._exceptions import OpenAIContentFilterException from ._shared import ( - DEFAULT_AZURE_OPENAI_RESPONSES_API_VERSION, - get_api_key, + AzureTokenProvider, load_openai_service_settings, maybe_append_azure_endpoint_guidance, ) @@ -107,14 +104,15 @@ else: from typing_extensions import TypedDict # type: ignore # pragma: no cover if TYPE_CHECKING: - from agent_framework._middleware import ( - ChatMiddleware, - ChatMiddlewareCallable, - FunctionMiddleware, - FunctionMiddlewareCallable, - ) + from azure.core.credentials import TokenCredential + from azure.core.credentials_async import AsyncTokenCredential + + AzureCredentialTypes = TokenCredential | AsyncTokenCredential logger = logging.getLogger("agent_framework.openai") + +DEFAULT_AZURE_OPENAI_RESPONSES_API_VERSION = "preview" + OPENAI_SHELL_ENVIRONMENT_KEY = "openai.responses.shell.environment" OPENAI_SHELL_OUTPUT_TYPE_KEY = "openai.responses.shell.output_type" OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY = "openai.responses.local_shell.call_item_id" @@ -139,7 +137,7 @@ class ReasoningOptions(TypedDict, total=False): See: https://platform.openai.com/docs/guides/reasoning """ - effort: Literal["low", "medium", "high"] + effort: Literal["none", "low", "medium", "high", "xhigh"] """The effort level for reasoning. Higher effort means more reasoning tokens.""" summary: Literal["auto", "concise", "detailed"] @@ -272,8 +270,8 @@ class RawOpenAIChatClient( # type: ignore[misc] @overload def __init__( self, - *, model: str | None = None, + *, api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, org_id: str | None = None, base_url: str | None = None, @@ -282,31 +280,77 @@ class RawOpenAIChatClient( # type: ignore[misc] instruction_role: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - ) -> None: ... + ) -> None: + """Initialize a raw OpenAI Chat client. + + Keyword Args: + model: Model identifier to use for the request. When not provided, the constructor + reads ``OPENAI_RESPONSES_MODEL`` and then ``OPENAI_MODEL``. + api_key: API key. When not provided explicitly, the constructor reads + ``OPENAI_API_KEY``. A callable API key is also supported. + org_id: OpenAI organization ID. When not provided explicitly, the constructor reads + ``OPENAI_ORG_ID``. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``OPENAI_BASE_URL``. + default_headers: Additional HTTP headers. + async_client: Pre-configured OpenAI client. + instruction_role: Role for instruction messages (for example ``"system"``). + env_file_path: Optional ``.env`` file that is checked before the process environment + for ``OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... @overload def __init__( self, - *, model: str | None = None, - api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, - org_id: str | None = None, - base_url: str | None = None, + *, azure_endpoint: str, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, api_version: str | None = None, + api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, + base_url: str | None = None, default_headers: Mapping[str, str] | None = None, async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, instruction_role: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - ) -> None: ... + ) -> None: + """Initialize a raw OpenAI Chat client. + + Keyword Args: + model: Model identifier to use for the request. When not provided, the constructor + reads ``AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`` and then + ``AZURE_OPENAI_DEPLOYMENT_NAME``. + azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor + reads ``AZURE_OPENAI_ENDPOINT``. + credential: Azure credential or token provider for Entra auth. + api_version: Azure API version. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_API_VERSION`` and then uses the Responses default. + api_key: API key. For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key + auth. A callable token provider is also accepted, + but ``credential`` is the preferred Azure auth surface. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_BASE_URL``. Use this instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + default_headers: Additional HTTP headers. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. + instruction_role: Role for instruction messages (for example ``"system"``). + env_file_path: Optional ``.env`` file that is checked before process environment + variables for ``AZURE_OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... def __init__( self, - *, model: str | None = None, + *, model_id: str | None = None, api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, org_id: str | None = None, base_url: str | None = None, azure_endpoint: str | None = None, @@ -318,29 +362,53 @@ class RawOpenAIChatClient( # type: ignore[misc] env_file_encoding: str | None = None, **kwargs: Any, ) -> None: - """Initialize a raw OpenAI Responses client. + """Initialize a raw OpenAI Chat client. Keyword Args: - model: OpenAI model name. + model: Model identifier to use for the request. When not provided, the constructor + reads ``OPENAI_RESPONSES_MODEL`` and then ``OPENAI_MODEL`` for OpenAI, + or ``AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`` and then + ``AZURE_OPENAI_DEPLOYMENT_NAME`` for Azure. model_id: Deprecated alias for ``model``. - api_key: OpenAI API key, SecretString, or callable returning a key. - org_id: OpenAI organization ID. - base_url: Custom API base URL. - azure_endpoint: Azure OpenAI endpoint. When provided, the client uses - ``AsyncAzureOpenAI`` instead of ``AsyncOpenAI``. The value should be the - resource endpoint and should not end with ``/openai/v1``. For Azure OpenAI - key auth, either pass the resource endpoint without that suffix to - ``azure_endpoint`` or pass the full ``.../openai/v1`` URL to ``base_url``. - Can also be set via ``AZURE_OPENAI_ENDPOINT`` when no ``OPENAI_BASE_URL`` - is configured. - api_version: Azure OpenAI API version. Can also be set via - ``AZURE_OPENAI_API_VERSION``. + api_key: API key override. For OpenAI this maps to ``OPENAI_API_KEY``. + For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key + auth. A callable token provider is also accepted for backwards compatibility, + but ``credential`` is the preferred Azure auth surface. + credential: Azure credential or token provider for Azure OpenAI auth. Passing this + is an explicit Azure signal, even when ``OPENAI_API_KEY`` is also configured. + Credential objects require the optional ``azure-identity`` package. + org_id: OpenAI organization ID. Used only for OpenAI and resolved from + ``OPENAI_ORG_ID`` when not provided. + base_url: Base URL override. For OpenAI this maps to ``OPENAI_BASE_URL``. + For Azure this may be used instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + azure_endpoint: Azure resource endpoint. When not provided explicitly, Azure + falls back to ``AZURE_OPENAI_ENDPOINT``. + api_version: Azure API version to use once Azure routing is selected. When + not provided explicitly, Azure routing falls back to + ``AZURE_OPENAI_API_VERSION`` and then the Responses default. default_headers: Additional HTTP headers. - async_client: Pre-configured AsyncOpenAI client (skips client creation). - instruction_role: Role for instruction messages (e.g. ``"system"``). - env_file_path: Path to .env file for settings. - env_file_encoding: Encoding for .env file. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. + instruction_role: Role for instruction messages (for example ``"system"``). + env_file_path: Optional ``.env`` file that is checked before process environment + variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*`` + lookups. + env_file_encoding: Encoding for the ``.env`` file. kwargs: Additional keyword arguments forwarded to ``BaseChatClient``. + + Notes: + Environment resolution and routing precedence are: + + 1. Explicit Azure inputs (``azure_endpoint`` or ``credential``) + 2. Explicit OpenAI API key or ``OPENAI_API_KEY`` + 3. Azure environment fallback + + OpenAI routing reads ``OPENAI_API_KEY``, ``OPENAI_RESPONSES_MODEL``, + ``OPENAI_MODEL``, ``OPENAI_ORG_ID``, and ``OPENAI_BASE_URL``. Azure routing + reads ``AZURE_OPENAI_ENDPOINT``, ``AZURE_OPENAI_BASE_URL``, + ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME``, + ``AZURE_OPENAI_DEPLOYMENT_NAME``, and ``AZURE_OPENAI_API_VERSION``. """ if model_id is not None and model is None: import warnings @@ -348,98 +416,39 @@ class RawOpenAIChatClient( # type: ignore[misc] warnings.warn("model_id is deprecated, use model instead", DeprecationWarning, stacklevel=2) model = model_id - openai_settings: dict[str, Any] = {} - use_azure_client = isinstance(async_client, AsyncAzureOpenAI) - if not async_client: - resolved_settings, use_azure_client = load_openai_service_settings( - model=model, - api_key=api_key, - org_id=org_id, - base_url=base_url, - azure_endpoint=azure_endpoint, - api_version=api_version, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - azure_model_env_vars=("AZURE_OPENAI_DEPLOYMENT_NAME",), - default_azure_api_version=DEFAULT_AZURE_OPENAI_RESPONSES_API_VERSION, - ) - openai_settings = dict(resolved_settings) + settings, client, use_azure_client = load_openai_service_settings( + model=model, + api_key=api_key, + credential=credential, + org_id=org_id, + base_url=base_url, + endpoint=azure_endpoint, + api_version=api_version, + default_azure_api_version=DEFAULT_AZURE_OPENAI_RESPONSES_API_VERSION, + default_headers=default_headers, + client=async_client, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + openai_model_fields=("responses_model", "model"), + azure_deployment_fields=("responses_deployment_name", "deployment_name"), + responses_mode=True, + ) - api_key_value = openai_settings.get("api_key") - if not api_key_value: - raise ValueError( - "OpenAI API key is required. Set via the 'api_key' parameter or the " - "'OPENAI_API_KEY' or 'AZURE_OPENAI_API_KEY' environment variables." - ) - resolved_model = openai_settings.get("model") or model - if not resolved_model: - raise ValueError( - "OpenAI model is required. Set via the 'model' parameter or the " - "'OPENAI_MODEL' or 'AZURE_OPENAI_DEPLOYMENT_NAME' environment variables." - ) - model = resolved_model - - resolved_api_key = get_api_key(api_key_value) - - # Merge APP_INFO into the headers - merged_headers = dict(copy(default_headers)) if default_headers else {} - if APP_INFO: - merged_headers.update(APP_INFO) - merged_headers = prepend_agent_framework_to_user_agent(merged_headers) - - client_args: dict[str, Any] = {"api_key": resolved_api_key, "default_headers": merged_headers} - if use_azure_client: - endpoint_value = openai_settings.get("azure_endpoint") - if ( - not openai_settings.get("base_url") - and endpoint_value - and (hostname := urlparse(str(endpoint_value)).hostname) - and hostname.endswith(".openai.azure.com") - ): - openai_settings["base_url"] = urljoin(str(endpoint_value), "/openai/v1/") - - client_args.pop("api_key") - if resolved_api_version := openai_settings.get("api_version"): - client_args["api_version"] = resolved_api_version - if resolved_base_url := openai_settings.get("base_url"): - client_args["base_url"] = resolved_base_url - elif resolved_azure_endpoint := openai_settings.get("azure_endpoint"): - client_args["azure_endpoint"] = resolved_azure_endpoint - if callable(resolved_api_key): - client_args["azure_ad_token_provider"] = resolved_api_key - else: - client_args["api_key"] = resolved_api_key - client_args["azure_deployment"] = resolved_model - async_client = AsyncAzureOpenAI(**client_args) - else: - if resolved_org_id := openai_settings.get("org_id"): - client_args["organization"] = resolved_org_id - if resolved_base_url := openai_settings.get("base_url"): - client_args["base_url"] = resolved_base_url - - async_client = AsyncOpenAI(**client_args) - - self.client = async_client - self.model: str | None = model.strip() if model else None + self.client = client + self.model: str = settings.get("model") or settings.get("deployment_name") or "" # Store configuration for serialization - resolved_base_url = openai_settings.get("base_url") or base_url - resolved_azure_endpoint = openai_settings.get("azure_endpoint") or azure_endpoint - resolved_api_version = openai_settings.get("api_version") or api_version - self.org_id = openai_settings.get("org_id") or org_id - self.base_url = str(resolved_base_url) if resolved_base_url else None - self.azure_endpoint = str(resolved_azure_endpoint) if resolved_azure_endpoint else None - self.api_version = str(resolved_api_version) if use_azure_client and resolved_api_version else None + self.org_id = settings.get("org_id") + self.base_url = settings.get("base_url") + self.azure_endpoint = settings.get("endpoint") + self.api_version = settings.get("api_version") if default_headers: self.default_headers: dict[str, Any] | None = { k: v for k, v in default_headers.items() if k != USER_AGENT_KEY } else: self.default_headers = None - - if instruction_role is not None: - self.instruction_role = instruction_role - + self.instruction_role = instruction_role if use_azure_client: self.OTEL_PROVIDER_NAME = "azure.ai.openai" # type: ignore[misc] @@ -2452,8 +2461,8 @@ class OpenAIChatClient( # type: ignore[misc] @overload def __init__( self, - *, model: str | None = None, + *, api_key: str | Callable[[], str | Awaitable[str]] | None = None, org_id: str | None = None, base_url: str | None = None, @@ -2462,38 +2471,84 @@ class OpenAIChatClient( # type: ignore[misc] instruction_role: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - middleware: ( - Sequence[ChatMiddleware | ChatMiddlewareCallable | FunctionMiddleware | FunctionMiddlewareCallable] | None - ) = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, - ) -> None: ... + ) -> None: + """Initialize an OpenAI Responses client. + + Keyword Args: + model: Model identifier to use for the request. When not provided, the constructor + reads ``OPENAI_RESPONSES_MODEL`` and then ``OPENAI_MODEL``. + api_key: API key. When not provided explicitly, the constructor reads + ``OPENAI_API_KEY``. A callable API key is also supported. + org_id: OpenAI organization ID. When not provided explicitly, the constructor reads + ``OPENAI_ORG_ID``. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``OPENAI_BASE_URL``. + default_headers: Additional HTTP headers. + async_client: Pre-configured OpenAI client. + instruction_role: Role for instruction messages (for example ``"system"``). + env_file_path: Optional ``.env`` file that is checked before the process environment + for ``OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + middleware: Optional middleware to apply to the client. + function_invocation_configuration: Optional function invocation configuration override. + """ + ... @overload def __init__( self, - *, model: str | None = None, - api_key: str | Callable[[], str | Awaitable[str]] | None = None, - org_id: str | None = None, - base_url: str | None = None, - azure_endpoint: str, + *, + azure_endpoint: str | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, api_version: str | None = None, + api_key: str | Callable[[], str | Awaitable[str]] | None = None, + base_url: str | None = None, default_headers: Mapping[str, str] | None = None, async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, instruction_role: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - middleware: ( - Sequence[ChatMiddleware | ChatMiddlewareCallable | FunctionMiddleware | FunctionMiddlewareCallable] | None - ) = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, - ) -> None: ... + ) -> None: + """Initialize an OpenAI Responses client. + + Keyword Args: + model: Model identifier to use for the request. When not provided, the constructor + reads ``AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`` and then + ``AZURE_OPENAI_DEPLOYMENT_NAME``. + azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor + reads ``AZURE_OPENAI_ENDPOINT``. + credential: Azure credential or token provider for Entra auth. + api_version: Azure API version. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_API_VERSION`` and then uses the Responses default. + api_key: API key. For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key + auth. A callable token provider is also accepted, but ``credential`` is the preferred + Azure auth surface. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_BASE_URL``. Use this instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + default_headers: Additional HTTP headers. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. + instruction_role: Role for instruction messages (for example ``"system"``). + env_file_path: Optional ``.env`` file that is checked before process environment + variables for ``AZURE_OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + middleware: Optional middleware to apply to the client. + function_invocation_configuration: Optional function invocation configuration override. + """ + ... def __init__( self, - *, model: str | None = None, + *, api_key: str | Callable[[], str | Awaitable[str]] | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, org_id: str | None = None, base_url: str | None = None, azure_endpoint: str | None = None, @@ -2503,43 +2558,59 @@ class OpenAIChatClient( # type: ignore[misc] instruction_role: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - middleware: ( - Sequence[ChatMiddleware | ChatMiddlewareCallable | FunctionMiddleware | FunctionMiddlewareCallable] | None - ) = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, **kwargs: Any, ) -> None: """Initialize an OpenAI Responses client. Keyword Args: - model: OpenAI model name, see https://platform.openai.com/docs/models. - Can also be set via environment variable OPENAI_MODEL. - api_key: The API key to use. If provided will override the env vars or .env file value. - Can also be set via environment variable OPENAI_API_KEY. - org_id: The org ID to use. If provided will override the env vars or .env file value. - Can also be set via environment variable OPENAI_ORG_ID. - base_url: The base URL to use. If provided will override the standard value. - Can also be set via environment variable OPENAI_BASE_URL. - azure_endpoint: Azure OpenAI endpoint. When provided, the client uses - ``AsyncAzureOpenAI``. The value should be the Azure resource endpoint and - should not end with ``/openai/v1``. For Azure OpenAI key auth, either pass - the resource endpoint without that suffix to ``azure_endpoint`` or pass the - full ``.../openai/v1`` URL to ``base_url`` instead. Can also be discovered - from ``AZURE_OPENAI_ENDPOINT`` when no OpenAI base URL is configured. - api_version: Azure OpenAI API version. Can also be set via - ``AZURE_OPENAI_API_VERSION``. - default_headers: The default headers mapping of string keys to - string values for HTTP requests. - async_client: An existing client to use. - instruction_role: The role to use for 'instruction' messages, for example, - "system" or "developer". If not provided, the default is "system". - env_file_path: Use the environment settings file as a fallback - to environment variables. - env_file_encoding: The encoding of the environment settings file. + model: Model identifier to use for the request. When not provided, the constructor + reads ``OPENAI_RESPONSES_MODEL`` and then ``OPENAI_MODEL`` for OpenAI + routing, or ``AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`` and then + ``AZURE_OPENAI_DEPLOYMENT_NAME`` for Azure routing. + api_key: API key override. For OpenAI routing this maps to ``OPENAI_API_KEY``. + For Azure routing this can be used instead of ``AZURE_OPENAI_API_KEY`` for key + auth. A callable token provider is also accepted for backwards compatibility, + but ``credential`` is the preferred Azure auth surface. + credential: Azure credential or token provider for Azure OpenAI auth. Passing this + is an explicit Azure signal, even when ``OPENAI_API_KEY`` is also configured. + Credential objects require the optional ``azure-identity`` package. + org_id: OpenAI organization ID. Used only for OpenAI routing and resolved from + ``OPENAI_ORG_ID`` when not provided. + base_url: Base URL override. For OpenAI routing this maps to ``OPENAI_BASE_URL``. + For Azure routing this may be used instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + azure_endpoint: Azure resource endpoint. When not provided explicitly, Azure routing + falls back to ``AZURE_OPENAI_ENDPOINT``. + api_version: Azure API version to use once Azure routing is selected. When + not provided explicitly, Azure routing falls back to + ``AZURE_OPENAI_API_VERSION`` and then the Responses default. + default_headers: Default HTTP headers that are merged into each request. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. + instruction_role: Role to use for instruction messages (for example ``"system"``). + env_file_path: Optional ``.env`` file that is checked before process environment + variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*`` + lookups. + env_file_encoding: Encoding for the ``.env`` file. middleware: Optional middleware to apply to the client. function_invocation_configuration: Optional function invocation configuration override. kwargs: Other keyword parameters. + Notes: + Environment resolution and routing precedence are: + + 1. Explicit Azure inputs (``azure_endpoint`` or ``credential``) + 2. Explicit OpenAI API key or ``OPENAI_API_KEY`` + 3. Azure environment fallback + + OpenAI routing reads ``OPENAI_API_KEY``, ``OPENAI_RESPONSES_MODEL``, + ``OPENAI_MODEL``, ``OPENAI_ORG_ID``, and ``OPENAI_BASE_URL``. Azure routing + reads ``AZURE_OPENAI_ENDPOINT``, ``AZURE_OPENAI_BASE_URL``, + ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME``, + ``AZURE_OPENAI_DEPLOYMENT_NAME``, and ``AZURE_OPENAI_API_VERSION``. + Examples: .. code-block:: python @@ -2571,6 +2642,7 @@ class OpenAIChatClient( # type: ignore[misc] super().__init__( model=model, api_key=api_key, + credential=credential, org_id=org_id, base_url=base_url, azure_endpoint=azure_endpoint, diff --git a/python/packages/openai/agent_framework_openai/_chat_completion_client.py b/python/packages/openai/agent_framework_openai/_chat_completion_client.py index aa78079dd2..514d0a2991 100644 --- a/python/packages/openai/agent_framework_openai/_chat_completion_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -13,16 +13,15 @@ from collections.abc import ( MutableMapping, Sequence, ) -from copy import copy from datetime import datetime, timezone from itertools import chain -from typing import Any, ClassVar, Generic, Literal, cast, overload +from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, cast, overload from agent_framework._clients import BaseChatClient from agent_framework._docstrings import apply_layered_docstring from agent_framework._middleware import ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer from agent_framework._settings import SecretString -from agent_framework._telemetry import APP_INFO, USER_AGENT_KEY, prepend_agent_framework_to_user_agent +from agent_framework._telemetry import USER_AGENT_KEY from agent_framework._tools import ( FunctionInvocationConfiguration, FunctionInvocationLayer, @@ -59,8 +58,7 @@ from pydantic import BaseModel from ._exceptions import OpenAIContentFilterException from ._shared import ( - DEFAULT_AZURE_OPENAI_CHAT_COMPLETION_API_VERSION, - get_api_key, + AzureTokenProvider, load_openai_service_settings, maybe_append_azure_endpoint_guidance, ) @@ -78,8 +76,16 @@ if sys.version_info >= (3, 11): else: from typing_extensions import TypedDict # type: ignore # pragma: no cover +if TYPE_CHECKING: + from azure.core.credentials import TokenCredential + from azure.core.credentials_async import AsyncTokenCredential + + AzureCredentialTypes = TokenCredential | AsyncTokenCredential + logger = logging.getLogger("agent_framework.openai") +DEFAULT_AZURE_OPENAI_CHAT_COMPLETION_API_VERSION = "2024-12-01-preview" + ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel) ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) @@ -179,8 +185,8 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] @overload def __init__( self, - *, model: str | None = None, + *, api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, org_id: str | None = None, base_url: str | None = None, @@ -189,31 +195,77 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] instruction_role: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - ) -> None: ... + ) -> None: + """Initialize a raw OpenAI Chat completion client. + + Keyword Args: + model: Model identifier to use for the request. When not provided, the constructor + reads ``OPENAI_CHAT_MODEL`` and then ``OPENAI_MODEL``. + api_key: API key. When not provided explicitly, the constructor reads + ``OPENAI_API_KEY``. A callable API key is also supported. + org_id: OpenAI organization ID. When not provided explicitly, the constructor reads + ``OPENAI_ORG_ID``. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``OPENAI_BASE_URL``. + default_headers: Additional HTTP headers. + async_client: Pre-configured OpenAI client. + instruction_role: Role for instruction messages (for example ``"system"``). + env_file_path: Optional ``.env`` file that is checked before the process environment + for ``OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... @overload def __init__( self, - *, model: str | None = None, - api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, - org_id: str | None = None, - base_url: str | None = None, - azure_endpoint: str, + *, + azure_endpoint: str | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, api_version: str | None = None, + api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, + base_url: str | None = None, default_headers: Mapping[str, str] | None = None, async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, instruction_role: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - ) -> None: ... + ) -> None: + """Initialize a raw OpenAI Chat completion client. + + Keyword Args: + model: Model identifier to use for the request. When not provided, the constructor + reads ``AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`` and then + ``AZURE_OPENAI_DEPLOYMENT_NAME``. + azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor + reads ``AZURE_OPENAI_ENDPOINT``. + credential: Azure credential or token provider for Entra auth. + api_version: Azure API version. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_API_VERSION`` and then uses the Chat Completions default. + api_key: API key. For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key + auth. A callable token provider is also accepted, but ``credential`` is the preferred + Azure auth surface. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_BASE_URL``. Use this instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + default_headers: Additional HTTP headers. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. + instruction_role: Role for instruction messages (for example ``"system"``). + env_file_path: Optional ``.env`` file that is checked before process environment + variables for ``AZURE_OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... def __init__( self, - *, model: str | None = None, + *, model_id: str | None = None, api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, org_id: str | None = None, base_url: str | None = None, azure_endpoint: str | None = None, @@ -228,26 +280,50 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] """Initialize a raw OpenAI Chat completion client. Keyword Args: - model: OpenAI model name. + model: Model identifier to use for the request. When not provided, the constructor + reads ``OPENAI_CHAT_MODEL`` and then ``OPENAI_MODEL`` for OpenAI routing, + or ``AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`` and then + ``AZURE_OPENAI_DEPLOYMENT_NAME`` for Azure routing. model_id: Deprecated alias for ``model``. - api_key: OpenAI API key, SecretString, or callable returning a key. - org_id: OpenAI organization ID. - base_url: Custom API base URL. - azure_endpoint: Azure OpenAI endpoint. When provided, the client uses - ``AsyncAzureOpenAI`` instead of ``AsyncOpenAI``. The value should be the - resource endpoint and should not end with ``/openai/v1``. For Azure OpenAI - key auth, either pass the resource endpoint without that suffix to - ``azure_endpoint`` or pass the full ``.../openai/v1`` URL to ``base_url``. - Can also be set via ``AZURE_OPENAI_ENDPOINT`` when no ``OPENAI_BASE_URL`` - is configured. - api_version: Azure OpenAI API version. Can also be set via - ``AZURE_OPENAI_API_VERSION``. + api_key: API key override. For OpenAI routing this maps to ``OPENAI_API_KEY``. + For Azure routing this can be used instead of ``AZURE_OPENAI_API_KEY`` for key + auth. A callable token provider is also accepted for backwards compatibility, + but ``credential`` is the preferred Azure auth surface. + credential: Azure credential or token provider for Azure OpenAI auth. Passing this + is an explicit Azure signal, even when ``OPENAI_API_KEY`` is also configured. + Credential objects require the optional ``azure-identity`` package. + org_id: OpenAI organization ID. Used only for OpenAI routing and resolved from + ``OPENAI_ORG_ID`` when not provided. + base_url: Base URL override. For OpenAI routing this maps to ``OPENAI_BASE_URL``. + For Azure routing this may be used instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + azure_endpoint: Azure resource endpoint. When not provided explicitly, Azure routing + falls back to ``AZURE_OPENAI_ENDPOINT``. + api_version: Azure API version to use once Azure routing is selected. When + not provided explicitly, Azure routing falls back to + ``AZURE_OPENAI_API_VERSION`` and then the Chat Completions default. default_headers: Additional HTTP headers. - async_client: Pre-configured AsyncOpenAI client (skips client creation). - instruction_role: Role for instruction messages (e.g. ``"system"``). - env_file_path: Path to .env file for settings. - env_file_encoding: Encoding for .env file. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. + instruction_role: Role for instruction messages (for example ``"system"``). + env_file_path: Optional ``.env`` file that is checked before process environment + variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*`` + lookups. + env_file_encoding: Encoding for the ``.env`` file. kwargs: Additional keyword arguments forwarded to ``BaseChatClient``. + + Notes: + Environment resolution and routing precedence are: + + 1. Explicit Azure inputs (``azure_endpoint`` or ``credential``) + 2. Explicit OpenAI API key or ``OPENAI_API_KEY`` + 3. Azure environment fallback + + OpenAI routing reads ``OPENAI_API_KEY``, ``OPENAI_CHAT_MODEL``, + ``OPENAI_MODEL``, ``OPENAI_ORG_ID``, and ``OPENAI_BASE_URL``. Azure routing + reads ``AZURE_OPENAI_ENDPOINT``, ``AZURE_OPENAI_BASE_URL``, + ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_CHAT_DEPLOYMENT_NAME``, + ``AZURE_OPENAI_DEPLOYMENT_NAME``, and ``AZURE_OPENAI_API_VERSION``. """ if model_id is not None and model is None: import warnings @@ -255,89 +331,38 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] warnings.warn("model_id is deprecated, use model instead", DeprecationWarning, stacklevel=2) model = model_id - openai_settings: dict[str, Any] = {} - use_azure_client = isinstance(async_client, AsyncAzureOpenAI) - if not async_client: - resolved_settings, use_azure_client = load_openai_service_settings( - model=model, - api_key=api_key, - org_id=org_id, - base_url=base_url, - azure_endpoint=azure_endpoint, - api_version=api_version, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - azure_model_env_vars=("AZURE_OPENAI_DEPLOYMENT_NAME",), - default_azure_api_version=DEFAULT_AZURE_OPENAI_CHAT_COMPLETION_API_VERSION, - ) - openai_settings = dict(resolved_settings) + settings, client, use_azure_client = load_openai_service_settings( + model=model, + api_key=api_key, + credential=credential, + org_id=org_id, + base_url=base_url, + endpoint=azure_endpoint, + api_version=api_version, + default_azure_api_version=DEFAULT_AZURE_OPENAI_CHAT_COMPLETION_API_VERSION, + default_headers=default_headers, + client=async_client, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + openai_model_fields=("chat_model", "model"), + azure_deployment_fields=("chat_deployment_name", "deployment_name"), + ) - api_key_value = openai_settings.get("api_key") - if not api_key_value: - raise ValueError( - "OpenAI API key is required. Set via the 'api_key' parameter or the " - "'OPENAI_API_KEY' or 'AZURE_OPENAI_API_KEY' environment variables." - ) - resolved_model = openai_settings.get("model") or model - if not resolved_model: - raise ValueError( - "OpenAI model is required. Set via the 'model' parameter or the " - "'OPENAI_MODEL' or 'AZURE_OPENAI_DEPLOYMENT_NAME' environment variables." - ) - model = resolved_model - - resolved_api_key = get_api_key(api_key_value) - - # Merge APP_INFO into the headers - merged_headers = dict(copy(default_headers)) if default_headers else {} - if APP_INFO: - merged_headers.update(APP_INFO) - merged_headers = prepend_agent_framework_to_user_agent(merged_headers) - - client_args: dict[str, Any] = {"api_key": resolved_api_key, "default_headers": merged_headers} - if use_azure_client: - client_args.pop("api_key") - if resolved_api_version := openai_settings.get("api_version"): - client_args["api_version"] = resolved_api_version - if resolved_base_url := openai_settings.get("base_url"): - client_args["base_url"] = resolved_base_url - elif resolved_azure_endpoint := openai_settings.get("azure_endpoint"): - client_args["azure_endpoint"] = resolved_azure_endpoint - if callable(resolved_api_key): - client_args["azure_ad_token_provider"] = resolved_api_key - else: - client_args["api_key"] = resolved_api_key - client_args["azure_deployment"] = resolved_model - async_client = AsyncAzureOpenAI(**client_args) - else: - if resolved_org_id := openai_settings.get("org_id"): - client_args["organization"] = resolved_org_id - if resolved_base_url := openai_settings.get("base_url"): - client_args["base_url"] = resolved_base_url - - async_client = AsyncOpenAI(**client_args) - - self.client = async_client - self.model: str | None = model.strip() if model else None + self.client = client + self.model: str = settings.get("model") or settings.get("deployment_name") or "" # Store configuration for serialization - resolved_base_url = openai_settings.get("base_url") or base_url - resolved_azure_endpoint = openai_settings.get("azure_endpoint") or azure_endpoint - resolved_api_version = openai_settings.get("api_version") or api_version - self.org_id = openai_settings.get("org_id") or org_id - self.base_url = str(resolved_base_url) if resolved_base_url else None - self.azure_endpoint = str(resolved_azure_endpoint) if resolved_azure_endpoint else None - self.api_version = str(resolved_api_version) if use_azure_client and resolved_api_version else None + self.org_id = settings.get("org_id") + self.base_url = settings.get("base_url") + self.azure_endpoint = settings.get("endpoint") + self.api_version = settings.get("api_version") if default_headers: self.default_headers: dict[str, Any] | None = { k: v for k, v in default_headers.items() if k != USER_AGENT_KEY } else: self.default_headers = None - - if instruction_role is not None: - self.instruction_role = instruction_role - + self.instruction_role = instruction_role if use_azure_client: self.OTEL_PROVIDER_NAME = "azure.ai.openai" # type: ignore[misc] @@ -977,6 +1002,202 @@ class OpenAIChatCompletionClient( # type: ignore[misc] OTEL_PROVIDER_NAME: ClassVar[str] = "openai" # type: ignore[reportIncompatibleVariableOverride, misc] + @overload + def __init__( + self, + model: str | None = None, + *, + api_key: str | Callable[[], str | Awaitable[str]] | None = None, + org_id: str | None = None, + base_url: str | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncOpenAI | None = None, + instruction_role: str | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + function_invocation_configuration: FunctionInvocationConfiguration | None = None, + ) -> None: + """Initialize an OpenAI Chat completion client. + + Keyword Args: + model: Model identifier to use for the request. When not provided, the constructor + reads ``OPENAI_CHAT_MODEL`` and then ``OPENAI_MODEL``. + api_key: API key. When not provided explicitly, the constructor reads + ``OPENAI_API_KEY``. A callable API key is also supported. + org_id: OpenAI organization ID. When not provided explicitly, the constructor reads + ``OPENAI_ORG_ID``. + default_headers: Additional HTTP headers. + async_client: Pre-configured OpenAI client. + instruction_role: Role for instruction messages (for example ``"system"``). + base_url: Base URL override. When not provided explicitly, the constructor reads + ``OPENAI_BASE_URL``. + env_file_path: Optional ``.env`` file that is checked before the process environment + for ``OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + middleware: Optional sequence of ChatAndFunctionMiddlewareTypes to apply to requests. + function_invocation_configuration: Optional configuration for function invocation support. + """ + ... + + @overload + def __init__( + self, + model: str | None = None, + *, + azure_endpoint: str | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, + api_version: str | None = None, + api_key: str | Callable[[], str | Awaitable[str]] | None = None, + base_url: str | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, + instruction_role: str | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + function_invocation_configuration: FunctionInvocationConfiguration | None = None, + ) -> None: + """Initialize an OpenAI Chat completion client. + + Keyword Args: + model: Model identifier to use for the request. When not provided, the constructor + reads ``AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`` and then + ``AZURE_OPENAI_DEPLOYMENT_NAME``. + azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor + reads ``AZURE_OPENAI_ENDPOINT``. + credential: Azure credential or token provider for Entra auth. + api_version: Azure API version. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_API_VERSION`` and then uses the Chat Completions default. + api_key: API key. For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key + auth. A callable token provider is also accepted, but ``credential`` is the preferred + Azure auth surface. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_BASE_URL``. Use this instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + default_headers: Additional HTTP headers. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. + instruction_role: Role for instruction messages (for example ``"system"``). + env_file_path: Optional ``.env`` file that is checked before process environment + variables for ``AZURE_OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + middleware: Optional sequence of ChatAndFunctionMiddlewareTypes to apply to requests. + function_invocation_configuration: Optional configuration for function invocation support. + """ + ... + + def __init__( + self, + model: str | None = None, + *, + api_key: str | Callable[[], str | Awaitable[str]] | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, + org_id: str | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncOpenAI | None = None, + instruction_role: str | None = None, + base_url: str | None = None, + azure_endpoint: str | None = None, + api_version: str | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + function_invocation_configuration: FunctionInvocationConfiguration | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize an OpenAI Chat completion client. + + Keyword Args: + model: Model identifier to use for the request. When not provided, the constructor + reads ``OPENAI_CHAT_MODEL`` and then ``OPENAI_MODEL`` for OpenAI routing, + or ``AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`` and then + ``AZURE_OPENAI_DEPLOYMENT_NAME`` for Azure routing. + api_key: API key override. For OpenAI routing this maps to ``OPENAI_API_KEY``. + For Azure routing this can be used instead of ``AZURE_OPENAI_API_KEY`` for key + auth. A callable token provider is also accepted for backwards compatibility, + but ``credential`` is the preferred Azure auth surface. + credential: Azure credential or token provider for Azure OpenAI auth. Passing this + is an explicit Azure signal, even when ``OPENAI_API_KEY`` is also configured. + Credential objects require the optional ``azure-identity`` package. + org_id: OpenAI organization ID. Used only for OpenAI routing and resolved from + ``OPENAI_ORG_ID`` when not provided. + default_headers: Default HTTP headers that are merged into each request. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. + instruction_role: Role to use for instruction messages (for example ``"system"``). + base_url: Base URL override. For OpenAI routing this maps to ``OPENAI_BASE_URL``. + For Azure routing this may be used instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + azure_endpoint: Azure resource endpoint. When not provided explicitly, Azure routing + falls back to ``AZURE_OPENAI_ENDPOINT``. + api_version: Azure API version to use once Azure routing is selected. When + not provided explicitly, Azure routing falls back to + ``AZURE_OPENAI_API_VERSION`` and then the Chat Completions default. + middleware: Optional sequence of ChatAndFunctionMiddlewareTypes to apply to requests. + function_invocation_configuration: Optional configuration for function invocation support. + env_file_path: Optional ``.env`` file that is checked before process environment + variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*`` + lookups. + env_file_encoding: Encoding for the ``.env`` file. + + Notes: + Environment resolution and routing precedence are: + + 1. Explicit Azure inputs (``azure_endpoint`` or ``credential``) + 2. Explicit OpenAI API key or ``OPENAI_API_KEY`` + 3. Azure environment fallback + + OpenAI routing reads ``OPENAI_API_KEY``, ``OPENAI_CHAT_MODEL``, + ``OPENAI_MODEL``, ``OPENAI_ORG_ID``, and ``OPENAI_BASE_URL``. Azure routing + reads ``AZURE_OPENAI_ENDPOINT``, ``AZURE_OPENAI_BASE_URL``, + ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_CHAT_DEPLOYMENT_NAME``, + ``AZURE_OPENAI_DEPLOYMENT_NAME``, and ``AZURE_OPENAI_API_VERSION``. + + Examples: + .. code-block:: python + + from agent_framework.openai import OpenAIChatCompletionClient + + # Using environment variables + # Set OPENAI_API_KEY=sk-... + # Set OPENAI_MODEL= + client = OpenAIChatCompletionClient() + + # Or passing parameters directly + client = OpenAIChatCompletionClient(model="", api_key="sk-...") + + # Or loading from a .env file + client = OpenAIChatCompletionClient(env_file_path="path/to/.env") + + # Using custom ChatOptions with type safety: + from typing import TypedDict + from agent_framework.openai import OpenAIChatCompletionOptions + + + class MyOptions(OpenAIChatCompletionOptions, total=False): + my_custom_option: str + + + client: OpenAIChatCompletionClient[MyOptions] = OpenAIChatCompletionClient(model="") + response = await client.get_response("Hello", options={"my_custom_option": "value"}) + """ + super().__init__( + model=model, + api_key=api_key, + credential=credential, + org_id=org_id, + base_url=base_url, + azure_endpoint=azure_endpoint, + api_version=api_version, + default_headers=default_headers, + async_client=async_client, + instruction_role=instruction_role, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + middleware=middleware, + function_invocation_configuration=function_invocation_configuration, + ) + @overload def get_response( self, @@ -1045,98 +1266,6 @@ class OpenAIChatCompletionClient( # type: ignore[misc] **kwargs, ) - def __init__( - self, - *, - model: str | None = None, - api_key: str | Callable[[], str | Awaitable[str]] | None = None, - org_id: str | None = None, - default_headers: Mapping[str, str] | None = None, - async_client: AsyncOpenAI | None = None, - instruction_role: str | None = None, - base_url: str | None = None, - azure_endpoint: str | None = None, - api_version: str | None = None, - middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, - function_invocation_configuration: FunctionInvocationConfiguration | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - ) -> None: - """Initialize an OpenAI Chat completion client. - - Keyword Args: - model: OpenAI model name, see https://platform.openai.com/docs/models. - Can also be set via environment variable OPENAI_MODEL. - api_key: The API key to use. If provided will override the env vars or .env file value. - Can also be set via environment variable OPENAI_API_KEY. - org_id: The org ID to use. If provided will override the env vars or .env file value. - Can also be set via environment variable OPENAI_ORG_ID. - default_headers: The default headers mapping of string keys to - string values for HTTP requests. - async_client: An existing client to use. - instruction_role: The role to use for 'instruction' messages, for example, - "system" or "developer". If not provided, the default is "system". - base_url: The base URL to use. If provided will override - the standard value for an OpenAI connector, the env vars or .env file value. - Can also be set via environment variable OPENAI_BASE_URL. - azure_endpoint: Azure OpenAI endpoint. When provided, the client uses - ``AsyncAzureOpenAI``. The value should be the Azure resource endpoint and - should not end with ``/openai/v1``. For Azure OpenAI key auth, either pass - the resource endpoint without that suffix to ``azure_endpoint`` or pass the - full ``.../openai/v1`` URL to ``base_url`` instead. Can also be discovered - from ``AZURE_OPENAI_ENDPOINT`` when no OpenAI base URL is configured. - api_version: Azure OpenAI API version. Can also be set via - ``AZURE_OPENAI_API_VERSION``. - middleware: Optional sequence of ChatAndFunctionMiddlewareTypes to apply to requests. - function_invocation_configuration: Optional configuration for function invocation support. - env_file_path: Use the environment settings file as a fallback - to environment variables. - env_file_encoding: The encoding of the environment settings file. - - Examples: - .. code-block:: python - - from agent_framework.openai import OpenAIChatCompletionClient - - # Using environment variables - # Set OPENAI_API_KEY=sk-... - # Set OPENAI_MODEL= - client = OpenAIChatCompletionClient() - - # Or passing parameters directly - client = OpenAIChatCompletionClient(model="", api_key="sk-...") - - # Or loading from a .env file - client = OpenAIChatCompletionClient(env_file_path="path/to/.env") - - # Using custom ChatOptions with type safety: - from typing import TypedDict - from agent_framework.openai import OpenAIChatCompletionOptions - - - class MyOptions(OpenAIChatCompletionOptions, total=False): - my_custom_option: str - - - client: OpenAIChatCompletionClient[MyOptions] = OpenAIChatCompletionClient(model="") - response = await client.get_response("Hello", options={"my_custom_option": "value"}) - """ - super().__init__( - model=model, - api_key=api_key, - org_id=org_id, - base_url=base_url, - azure_endpoint=azure_endpoint, - api_version=api_version, - default_headers=default_headers, - async_client=async_client, - instruction_role=instruction_role, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - middleware=middleware, - function_invocation_configuration=function_invocation_configuration, - ) - def _apply_openai_chat_completion_client_docstrings() -> None: """Align OpenAI chat completion client docstrings with the raw implementation.""" diff --git a/python/packages/openai/agent_framework_openai/_embedding_client.py b/python/packages/openai/agent_framework_openai/_embedding_client.py index ad959d5b39..9cb37ad4df 100644 --- a/python/packages/openai/agent_framework_openai/_embedding_client.py +++ b/python/packages/openai/agent_framework_openai/_embedding_client.py @@ -6,23 +6,31 @@ import base64 import struct import sys from collections.abc import Awaitable, Callable, Mapping, Sequence -from copy import copy -from typing import Any, ClassVar, Generic, Literal, TypedDict +from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, TypedDict, overload from agent_framework._clients import BaseEmbeddingClient -from agent_framework._settings import SecretString, load_settings -from agent_framework._telemetry import APP_INFO, USER_AGENT_KEY, prepend_agent_framework_to_user_agent +from agent_framework._settings import SecretString +from agent_framework._telemetry import USER_AGENT_KEY from agent_framework._types import Embedding, EmbeddingGenerationOptions, GeneratedEmbeddings, UsageDetails from agent_framework.observability import EmbeddingTelemetryLayer -from openai import AsyncOpenAI +from openai import AsyncAzureOpenAI, AsyncOpenAI -from ._shared import OpenAISettings, get_api_key +from ._shared import AzureTokenProvider, load_openai_service_settings if sys.version_info >= (3, 13): from typing import TypeVar # type: ignore # pragma: no cover else: from typing_extensions import TypeVar # type: ignore # pragma: no cover +if TYPE_CHECKING: + from azure.core.credentials import TokenCredential + from azure.core.credentials_async import AsyncTokenCredential + + AzureCredentialTypes = TokenCredential | AsyncTokenCredential + + +DEFAULT_AZURE_OPENAI_EMBEDDING_API_VERSION = "2024-10-21" + class OpenAIEmbeddingOptions(EmbeddingGenerationOptions, total=False): """OpenAI-specific embedding options. @@ -61,11 +69,11 @@ class RawOpenAIEmbeddingClient( INJECTABLE: ClassVar[set[str]] = {"client"} + @overload def __init__( self, *, model: str | None = None, - model_id: str | None = None, api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, org_id: str | None = None, base_url: str | None = None, @@ -73,21 +81,130 @@ class RawOpenAIEmbeddingClient( async_client: AsyncOpenAI | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, + ) -> None: + """Initialize a raw OpenAI embedding client. + + Keyword Args: + model: Embedding model identifier. When not provided, the constructor reads + ``OPENAI_EMBEDDING_MODEL`` and then ``OPENAI_MODEL``. + api_key: API key. When not provided explicitly, the constructor reads + ``OPENAI_API_KEY``. A callable API key is also supported. + org_id: OpenAI organization ID. When not provided explicitly, the constructor reads + ``OPENAI_ORG_ID``. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``OPENAI_BASE_URL``. + default_headers: Additional HTTP headers. + async_client: Pre-configured OpenAI client. + env_file_path: Optional ``.env`` file that is checked before the process environment + for ``OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... + + @overload + def __init__( + self, + *, + model: str | None = None, + azure_endpoint: str | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, + api_version: str | None = None, + api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, + base_url: str | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize a raw OpenAI embedding client. + + Keyword Args: + model: Embedding deployment name. When not provided, the constructor reads + ``AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME`` and then + ``AZURE_OPENAI_DEPLOYMENT_NAME``. + azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor + reads ``AZURE_OPENAI_ENDPOINT``. + credential: Azure credential or token provider for Entra auth. + api_version: Azure API version. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_API_VERSION`` and then uses the embedding default. + api_key: API key. For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key + auth. A callable token provider is also accepted, but ``credential`` is the preferred + Azure auth surface. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_BASE_URL``. Use this instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + default_headers: Additional HTTP headers. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI. + env_file_path: Optional ``.env`` file that is checked before process environment + variables for ``AZURE_OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... + + def __init__( + self, + *, + model: str | None = None, + model_id: str | None = None, + api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, + org_id: str | None = None, + base_url: str | None = None, + azure_endpoint: str | None = None, + api_version: str | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, **kwargs: Any, ) -> None: """Initialize a raw OpenAI embedding client. Keyword Args: - model: OpenAI embedding model name. + model: Embedding model or Azure OpenAI deployment name. When not provided, the + constructor reads ``OPENAI_EMBEDDING_MODEL`` and then ``OPENAI_MODEL`` + for OpenAI. For Azure it first checks ``AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME`` + and then ``AZURE_OPENAI_DEPLOYMENT_NAME``. model_id: Deprecated alias for ``model``. - api_key: OpenAI API key, SecretString, or callable returning a key. - org_id: OpenAI organization ID. - base_url: Custom API base URL. + api_key: API key override. For OpenAI this maps to ``OPENAI_API_KEY``. + For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key auth. + A callable token provider is also accepted for backwards compatibility, + but ``credential`` is the preferred Azure auth surface. + credential: Azure credential or token provider for Azure OpenAI auth. Passing this + is an explicit Azure signal, even when ``OPENAI_API_KEY`` is also configured. + Credential objects require the optional ``azure-identity`` package. + org_id: OpenAI organization ID. Used only for OpenAI and resolved from + ``OPENAI_ORG_ID`` when not provided. + base_url: Base URL override. For OpenAI this maps to ``OPENAI_BASE_URL``. + For Azure this may be used instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + azure_endpoint: Azure resource endpoint. When not provided explicitly, Azure + falls back to ``AZURE_OPENAI_ENDPOINT``. + api_version: Azure API version to use for Azure requests. When not provided explicitly, + Azure falls back to + ``AZURE_OPENAI_API_VERSION`` and then the embedding default. default_headers: Additional HTTP headers. - async_client: Pre-configured AsyncOpenAI client (skips client creation). - env_file_path: Path to .env file for settings. - env_file_encoding: Encoding for .env file. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI. + env_file_path: Optional ``.env`` file that is checked before process environment + variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*`` + lookups. + env_file_encoding: Encoding for the ``.env`` file. kwargs: Additional keyword arguments forwarded to ``BaseEmbeddingClient``. + + Notes: + Environment resolution precedence is: + + 1. Explicit Azure inputs (``azure_endpoint`` or ``credential``) + 2. Explicit OpenAI API key or ``OPENAI_API_KEY`` + 3. Azure environment fallback + + OpenAI reads ``OPENAI_API_KEY``, ``OPENAI_EMBEDDING_MODEL``, + ``OPENAI_MODEL``, ``OPENAI_ORG_ID``, and ``OPENAI_BASE_URL``. Azure reads + ``AZURE_OPENAI_ENDPOINT``, ``AZURE_OPENAI_BASE_URL``, + ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME``, + ``AZURE_OPENAI_DEPLOYMENT_NAME``, and ``AZURE_OPENAI_API_VERSION``. """ if model_id is not None and model is None: import warnings @@ -95,59 +212,40 @@ class RawOpenAIEmbeddingClient( warnings.warn("model_id is deprecated, use model instead", DeprecationWarning, stacklevel=2) model = model_id - if not async_client: - openai_settings = load_settings( - OpenAISettings, - env_prefix="OPENAI_", - api_key=api_key, - org_id=org_id, - base_url=base_url, - embedding_model=model, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) + settings, client, use_azure_client = load_openai_service_settings( + model=model, + api_key=api_key, + credential=credential, + org_id=org_id, + base_url=base_url, + endpoint=azure_endpoint, + api_version=api_version, + default_azure_api_version=DEFAULT_AZURE_OPENAI_EMBEDDING_API_VERSION, + default_headers=default_headers, + client=async_client, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + openai_model_fields=("embedding_model", "model"), + azure_deployment_fields=("embedding_deployment_name", "deployment_name"), + ) - api_key_value = openai_settings.get("api_key") - resolved_model = openai_settings.get("embedding_model") or model - - # Only create a client when we have enough configuration. - # Subclasses that manage their own client pass no args here - if api_key_value: - if not resolved_model: - raise ValueError( - "OpenAI embedding model is required. " - "Set via 'model' parameter or 'OPENAI_EMBEDDING_MODEL' environment variable." - ) - model = resolved_model - - resolved_api_key = get_api_key(api_key_value) - - # Merge APP_INFO into the headers - merged_headers = dict(copy(default_headers)) if default_headers else {} - if APP_INFO: - merged_headers.update(APP_INFO) - merged_headers = prepend_agent_framework_to_user_agent(merged_headers) - - client_args: dict[str, Any] = {"api_key": resolved_api_key, "default_headers": merged_headers} - if resolved_org_id := openai_settings.get("org_id"): - client_args["organization"] = resolved_org_id - if resolved_base_url := openai_settings.get("base_url"): - client_args["base_url"] = resolved_base_url - - async_client = AsyncOpenAI(**client_args) - - self.client = async_client - self.model: str | None = model.strip() if model else None + self.client = client + resolved_model = settings.get("model") or settings.get("deployment_name") + self.model: str | None = resolved_model.strip() if isinstance(resolved_model, str) and resolved_model else None # Store configuration for serialization - self.org_id = org_id - self.base_url = str(base_url) if base_url else None + self.org_id = settings.get("org_id") + self.base_url = settings.get("base_url") + self.azure_endpoint = settings.get("endpoint") + self.api_version = settings.get("api_version") if default_headers: self.default_headers: dict[str, Any] | None = { k: v for k, v in default_headers.items() if k != USER_AGENT_KEY } else: self.default_headers = None + if use_azure_client: + self.OTEL_PROVIDER_NAME = "azure.ai.openai" # type: ignore[misc] super().__init__(**kwargs) @@ -225,45 +323,11 @@ class OpenAIEmbeddingClient( RawOpenAIEmbeddingClient[OpenAIEmbeddingOptionsT], Generic[OpenAIEmbeddingOptionsT], ): - """OpenAI embedding client with telemetry support. - - Keyword Args: - model: The embedding model (e.g. "text-embedding-3-small"). - Can also be set via environment variable OPENAI_EMBEDDING_MODEL. - model_id: Deprecated alias for ``model``. - api_key: OpenAI API key. - Can also be set via environment variable OPENAI_API_KEY. - org_id: OpenAI organization ID. - default_headers: Additional HTTP headers. - async_client: Pre-configured AsyncOpenAI client. - base_url: Custom API base URL. - otel_provider_name: Override the OpenTelemetry provider name for telemetry. - env_file_path: Path to .env file for settings. - env_file_encoding: Encoding for .env file. - - Examples: - .. code-block:: python - - from agent_framework.openai import OpenAIEmbeddingClient - - # Using environment variables - # Set OPENAI_API_KEY=sk-... - # Set OPENAI_EMBEDDING_MODEL=text-embedding-3-small - client = OpenAIEmbeddingClient() - - # Or passing parameters directly - client = OpenAIEmbeddingClient( - model="text-embedding-3-small", - api_key="sk-...", - ) - - # Generate embeddings - result = await client.get_embeddings(["Hello, world!"]) - print(result[0].vector) - """ + """OpenAI embedding client with telemetry support.""" OTEL_PROVIDER_NAME: ClassVar[str] = "openai" # type: ignore[reportIncompatibleVariableOverride, misc] + @overload def __init__( self, *, @@ -277,27 +341,165 @@ class OpenAIEmbeddingClient( env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: - """Initialize an OpenAI embedding client.""" + """Initialize an OpenAI embedding client. + + Keyword Args: + model: Embedding model identifier. When not provided, the constructor reads + ``OPENAI_EMBEDDING_MODEL`` and then ``OPENAI_MODEL``. + api_key: API key. When not provided explicitly, the constructor reads + ``OPENAI_API_KEY``. A callable API key is also supported. + org_id: OpenAI organization ID. When not provided explicitly, the constructor reads + ``OPENAI_ORG_ID``. + default_headers: Additional HTTP headers. + async_client: Pre-configured OpenAI client. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``OPENAI_BASE_URL``. + otel_provider_name: Optional telemetry provider name override. + env_file_path: Optional ``.env`` file that is checked before the process environment + for ``OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... + + @overload + def __init__( + self, + *, + model: str | None = None, + azure_endpoint: str | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, + api_version: str | None = None, + api_key: str | Callable[[], str | Awaitable[str]] | None = None, + base_url: str | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, + otel_provider_name: str | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize an OpenAI embedding client. + + Keyword Args: + model: Embedding deployment name. When not provided, the constructor reads + ``AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME`` and then + ``AZURE_OPENAI_DEPLOYMENT_NAME``. + azure_endpoint: Azure resource endpoint. When not provided explicitly, the constructor + reads ``AZURE_OPENAI_ENDPOINT``. + credential: Azure credential or token provider for Entra auth. + api_version: Azure API version. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_API_VERSION`` and then uses the embedding default. + api_key: API key. For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key + auth. A callable token provider is also accepted, but ``credential`` is the preferred + Azure auth surface. + base_url: Base URL override. When not provided explicitly, the constructor reads + ``AZURE_OPENAI_BASE_URL``. Use this instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + default_headers: Additional HTTP headers. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI. + otel_provider_name: Optional telemetry provider name override. + env_file_path: Optional ``.env`` file that is checked before process environment + variables for ``AZURE_OPENAI_*`` values. + env_file_encoding: Encoding for the ``.env`` file. + """ + ... + + def __init__( + self, + *, + model: str | None = None, + api_key: str | Callable[[], str | Awaitable[str]] | None = None, + credential: AzureCredentialTypes | AzureTokenProvider | None = None, + org_id: str | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, + base_url: str | None = None, + azure_endpoint: str | None = None, + api_version: str | None = None, + otel_provider_name: str | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize an OpenAI embedding client. + + Keyword Args: + model: Embedding model or Azure OpenAI deployment name. When not provided, the + constructor reads ``OPENAI_EMBEDDING_MODEL`` and then ``OPENAI_MODEL`` + for OpenAI. For Azure it first checks ``AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME`` + and then ``AZURE_OPENAI_DEPLOYMENT_NAME``. + api_key: API key override. For OpenAI this maps to ``OPENAI_API_KEY``. + For Azure this can be used instead of ``AZURE_OPENAI_API_KEY`` for key auth. + A callable token provider is also accepted for backwards compatibility, + but ``credential`` is the preferred Azure auth surface. + credential: Azure credential or token provider for Azure OpenAI auth. Passing this + is an explicit Azure signal, even when ``OPENAI_API_KEY`` is also configured. + Credential objects require the optional ``azure-identity`` package. + org_id: OpenAI organization ID. Used only for OpenAI and resolved from + ``OPENAI_ORG_ID`` when not provided. + default_headers: Additional HTTP headers. + async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on + Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI. + base_url: Base URL override. For OpenAI this maps to ``OPENAI_BASE_URL``. + For Azure this may be used instead of ``azure_endpoint`` when you want + to pass the full ``.../openai/v1`` base URL directly. + azure_endpoint: Azure resource endpoint. When not provided explicitly, Azure + falls back to ``AZURE_OPENAI_ENDPOINT``. + api_version: Azure API version to use for Azure requests. When not provided explicitly, + Azure falls back to + ``AZURE_OPENAI_API_VERSION`` and then the embedding default. + otel_provider_name: Override the OpenTelemetry provider name. + env_file_path: Optional ``.env`` file that is checked before process environment + variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*`` + lookups. + env_file_encoding: Encoding for the ``.env`` file. + + Notes: + Environment resolution precedence is: + + 1. Explicit Azure inputs (``azure_endpoint`` or ``credential``) + 2. Explicit OpenAI API key or ``OPENAI_API_KEY`` + 3. Azure environment fallback + + OpenAI reads ``OPENAI_API_KEY``, ``OPENAI_EMBEDDING_MODEL``, + ``OPENAI_MODEL``, ``OPENAI_ORG_ID``, and ``OPENAI_BASE_URL``. Azure reads + ``AZURE_OPENAI_ENDPOINT``, ``AZURE_OPENAI_BASE_URL``, + ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME``, + ``AZURE_OPENAI_DEPLOYMENT_NAME``, and ``AZURE_OPENAI_API_VERSION``. + + Examples: + .. code-block:: python + + from agent_framework.openai import OpenAIEmbeddingClient + + # Using environment variables + # Set OPENAI_API_KEY=sk-... + # Set OPENAI_EMBEDDING_MODEL=text-embedding-3-small + client = OpenAIEmbeddingClient() + + # Or passing OpenAI parameters directly + client = OpenAIEmbeddingClient( + model="text-embedding-3-small", + api_key="sk-...", + ) + + # Or using Azure OpenAI with an Azure credential + client = OpenAIEmbeddingClient( + model="text-embedding-3-small", + azure_endpoint="https://example-resource.openai.azure.com/", + credential=my_azure_credential, + ) + """ super().__init__( model=model, api_key=api_key, + credential=credential, org_id=org_id, base_url=base_url, + azure_endpoint=azure_endpoint, + api_version=api_version, default_headers=default_headers, async_client=async_client, + otel_provider_name=otel_provider_name, env_file_path=env_file_path, env_file_encoding=env_file_encoding, ) - if otel_provider_name is not None: - self.OTEL_PROVIDER_NAME = otel_provider_name # type: ignore[misc] - - # Validate that the client was created successfully (from explicit args or env vars) - if self.client is None: - raise ValueError( - "OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable." - ) - if not self.model: - raise ValueError( - "OpenAI embedding model is required. " - "Set via 'model' parameter or 'OPENAI_EMBEDDING_MODEL' environment variable." - ) diff --git a/python/packages/openai/agent_framework_openai/_shared.py b/python/packages/openai/agent_framework_openai/_shared.py index c3c280d950..feb30cc7d5 100644 --- a/python/packages/openai/agent_framework_openai/_shared.py +++ b/python/packages/openai/agent_framework_openai/_shared.py @@ -3,19 +3,18 @@ from __future__ import annotations import logging -import os import sys from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequence from copy import copy -from typing import Any, ClassVar, Union, cast +from typing import TYPE_CHECKING, Any, ClassVar, Literal, Union, cast import openai from agent_framework._serialization import SerializationMixin from agent_framework._settings import SecretString, load_settings from agent_framework._telemetry import APP_INFO, USER_AGENT_KEY, prepend_agent_framework_to_user_agent from agent_framework._tools import FunctionTool -from dotenv import dotenv_values -from openai import AsyncOpenAI, AsyncStream, _legacy_response # type: ignore +from agent_framework.exceptions import SettingNotFoundError +from openai import AsyncAzureOpenAI, AsyncOpenAI, AsyncStream, _legacy_response # type: ignore from openai.types import Completion from openai.types.audio import Transcription from openai.types.chat import ChatCompletion, ChatCompletionChunk @@ -24,10 +23,21 @@ from openai.types.responses.response import Response from openai.types.responses.response_stream_event import ResponseStreamEvent from packaging.version import parse +if sys.version_info >= (3, 11): + from typing import TypedDict # type: ignore # pragma: no cover +else: + from typing_extensions import TypedDict # type: ignore # pragma: no cover + +if TYPE_CHECKING: + from azure.core.credentials import TokenCredential + from azure.core.credentials_async import AsyncTokenCredential + + AzureCredentialTypes = TokenCredential | AsyncTokenCredential + + logger: logging.Logger = logging.getLogger("agent_framework.openai") -DEFAULT_AZURE_OPENAI_CHAT_COMPLETION_API_VERSION = "2024-10-21" -DEFAULT_AZURE_OPENAI_RESPONSES_API_VERSION = "preview" +AZURE_OPENAI_TOKEN_SCOPE = "https://cognitiveservices.azure.com/.default" # noqa: S105 # nosec B105 RESPONSE_TYPE = Union[ @@ -43,12 +53,7 @@ RESPONSE_TYPE = Union[ _legacy_response.HttpxBinaryResponseContent, ] -OPTION_TYPE = dict[str, Any] - -if sys.version_info >= (3, 11): - from typing import TypedDict # type: ignore # pragma: no cover -else: - from typing_extensions import TypedDict # type: ignore # pragma: no cover +AzureTokenProvider = Callable[[], str | Awaitable[str]] def _check_openai_version_for_callable_api_key() -> None: @@ -92,6 +97,10 @@ class OpenAISettings(TypedDict, total=False): Can be set via environment variable OPENAI_MODEL. embedding_model: The OpenAI embedding model to use, for example, text-embedding-3-small. Can be set via environment variable OPENAI_EMBEDDING_MODEL. + chat_model: The OpenAI chat-completions model to prefer before OPENAI_MODEL. + Can be set via environment variable OPENAI_CHAT_MODEL. + responses_model: The OpenAI responses model to prefer before OPENAI_MODEL. + Can be set via environment variable OPENAI_RESPONSES_MODEL. Examples: .. code-block:: python @@ -110,122 +119,232 @@ class OpenAISettings(TypedDict, total=False): settings = load_settings(OpenAISettings, env_prefix="OPENAI_", env_file_path="path/to/.env") """ - api_key: SecretString | Callable[[], str | Awaitable[str]] | None + api_key: SecretString | None base_url: str | None org_id: str | None model: str | None embedding_model: str | None - azure_endpoint: str | None + chat_model: str | None + responses_model: str | None + + +class AzureOpenAISettings(TypedDict, total=False): + """Azure OpenAI environment settings.""" + + endpoint: str | None + base_url: str | None + api_key: SecretString | None + deployment_name: str | None + embedding_deployment_name: str | None + chat_deployment_name: str | None + responses_deployment_name: str | None api_version: str | None -def _load_dotenv_values(*, env_file_path: str | None, env_file_encoding: str | None) -> dict[str, str]: - """Load dotenv values for non-standard environment variable aliases.""" - if env_file_path is None or not os.path.exists(env_file_path): - return {} +OpenAIModelSettingName = Literal["model", "embedding_model", "chat_model", "responses_model"] +AzureDeploymentSettingName = Literal[ + "deployment_name", "embedding_deployment_name", "chat_deployment_name", "responses_deployment_name" +] - raw_dotenv_values = dotenv_values(dotenv_path=env_file_path, encoding=env_file_encoding or "utf-8") - return {key: value for key, value in raw_dotenv_values.items() if value is not None} +OPENAI_MODEL_ENV_VARS: dict[OpenAIModelSettingName, str] = { + "model": "OPENAI_MODEL", + "embedding_model": "OPENAI_EMBEDDING_MODEL", + "chat_model": "OPENAI_CHAT_MODEL", + "responses_model": "OPENAI_RESPONSES_MODEL", +} + +AZURE_DEPLOYMENT_ENV_VARS: dict[AzureDeploymentSettingName, str] = { + "deployment_name": "AZURE_OPENAI_DEPLOYMENT_NAME", + "embedding_deployment_name": "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", + "chat_deployment_name": "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", + "responses_deployment_name": "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", +} -def _get_setting_from_alias( - name: str, - *, - dotenv_values_by_name: Mapping[str, str], +def _resolve_named_setting( + settings: Mapping[str, Any], + fields: Sequence[OpenAIModelSettingName | AzureDeploymentSettingName], ) -> str | None: - """Resolve a setting from an explicit env-var alias.""" - if dotenv_value := dotenv_values_by_name.get(name): - return dotenv_value - return os.getenv(name) + """Return the first populated value from ``fields``.""" + for field in fields: + value = settings.get(field) + if isinstance(value, str) and value: + return value + return None + + +def _join_env_names(env_names: Sequence[str]) -> str: + """Format env var names for user-facing error messages.""" + return ", ".join(f"'{env_name}'" for env_name in env_names) def load_openai_service_settings( *, model: str | None, api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None, + credential: AzureCredentialTypes | AzureTokenProvider | None, org_id: str | None, base_url: str | None, - azure_endpoint: str | None, + endpoint: str | None, api_version: str | None, + default_azure_api_version: str, + default_headers: Mapping[str, str] | None = None, + client: AsyncOpenAI | None = None, env_file_path: str | None, env_file_encoding: str | None, - azure_model_env_vars: Sequence[str], - default_azure_api_version: str, -) -> tuple[OpenAISettings, bool]: + openai_model_fields: Sequence[OpenAIModelSettingName] = ("model",), + azure_deployment_fields: Sequence[AzureDeploymentSettingName] = ("deployment_name",), + responses_mode: bool = False, +) -> tuple[dict[str, Any], AsyncOpenAI, bool]: """Load OpenAI settings, including Azure OpenAI aliases. - The generic OpenAI clients primarily read from ``OPENAI_*`` variables. When an - ``AZURE_OPENAI_ENDPOINT`` (or ``AZURE_OPENAI_BASE_URL``) is available and no - explicit OpenAI base URL is configured, this helper switches to Azure-specific - environment variables for endpoint, API key, model deployment, and API version. + The generic OpenAI clients primarily read from ``OPENAI_*`` variables. Azure-specific + environment variables are used only when an explicit Azure signal is present + (``endpoint`` or ``credential``) or when no explicit + OpenAI API key is available. """ - openai_settings = load_settings( - OpenAISettings, - env_prefix="OPENAI_", - api_key=api_key, - org_id=org_id, - base_url=base_url, - model=model, - azure_endpoint=azure_endpoint, - api_version=api_version, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) + # Merge APP_INFO into the headers + merged_headers = dict(copy(default_headers)) if default_headers else {} + if APP_INFO: + merged_headers.update(APP_INFO) + merged_headers = prepend_agent_framework_to_user_agent(merged_headers) - dotenv_values_by_name = _load_dotenv_values( - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - - resolved_azure_endpoint = azure_endpoint - resolved_azure_base_url: str | None = None - if not openai_settings.get("base_url"): - if resolved_azure_endpoint is None: - resolved_azure_endpoint = _get_setting_from_alias( - "AZURE_OPENAI_ENDPOINT", - dotenv_values_by_name=dotenv_values_by_name, - ) - if resolved_azure_endpoint is None: - resolved_azure_base_url = _get_setting_from_alias( - "AZURE_OPENAI_BASE_URL", - dotenv_values_by_name=dotenv_values_by_name, - ) - if resolved_azure_base_url is not None: - openai_settings["base_url"] = resolved_azure_base_url - - use_azure_client = resolved_azure_endpoint is not None or resolved_azure_base_url is not None - if resolved_azure_endpoint is not None: - openai_settings["azure_endpoint"] = resolved_azure_endpoint - - if use_azure_client: - if api_key is None: - resolved_azure_api_key = _get_setting_from_alias( - "AZURE_OPENAI_API_KEY", - dotenv_values_by_name=dotenv_values_by_name, - ) - if resolved_azure_api_key is not None: - openai_settings["api_key"] = SecretString(resolved_azure_api_key) - - if model is None: - for env_var_name in azure_model_env_vars: - resolved_model = _get_setting_from_alias( - env_var_name, - dotenv_values_by_name=dotenv_values_by_name, + api_key_callable = api_key if callable(api_key) else None + api_key_str = api_key if not callable(api_key) else None + azure_client = isinstance(client, AsyncAzureOpenAI) + use_azure = azure_client or endpoint is not None or credential is not None + checked_openai = False + if not use_azure: + openai_settings_kwargs: dict[str, Any] = { + "api_key": api_key_str, + "org_id": org_id, + "base_url": base_url, + "env_file_path": env_file_path, + "env_file_encoding": env_file_encoding, + } + if model is not None: + openai_settings_kwargs[openai_model_fields[0]] = model + openai_settings = load_settings( + OpenAISettings, + env_prefix="OPENAI_", + **openai_settings_kwargs, + ) + if resolved_model := _resolve_named_setting(openai_settings, openai_model_fields): + openai_settings["model"] = resolved_model + if client: + return openai_settings, client, False # type: ignore[return-value] + if openai_settings.get("api_key") is not None or api_key_callable is not None: + resolved_model = _resolve_named_setting(openai_settings, openai_model_fields) + if not resolved_model: + raise SettingNotFoundError( + "Model must be specified via the 'model' parameter or the " + f"{_join_env_names([OPENAI_MODEL_ENV_VARS[field] for field in openai_model_fields])} " + "environment variable." ) - if resolved_model is not None: - openai_settings["model"] = resolved_model - break - if api_version is not None: - openai_settings["api_version"] = api_version - else: - resolved_api_version = _get_setting_from_alias( - "AZURE_OPENAI_API_VERSION", - dotenv_values_by_name=dotenv_values_by_name, + client_args: dict[str, Any] = { + "api_key": api_key_callable + if api_key_callable is not None + else openai_settings["api_key"].get_secret_value(), # type: ignore[reportOptionalMemberAccess, union-attr] + "organization": openai_settings.get("org_id"), + "default_headers": merged_headers, + } + if base_url := openai_settings.get("base_url"): + client_args["base_url"] = base_url + return openai_settings, AsyncOpenAI(**client_args), False # type: ignore[return-value] + checked_openai = True + azure_settings = load_settings( + AzureOpenAISettings, + env_prefix="AZURE_OPENAI_", + required_fields=None if client else [("base_url", "endpoint")], + api_key=api_key_str, + endpoint=endpoint, + base_url=base_url, + api_version=api_version or default_azure_api_version, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + if model is not None: + azure_settings[azure_deployment_fields[0]] = model + client_args = {} + resolved_azure_deployment = _resolve_named_setting(azure_settings, azure_deployment_fields) + if resolved_azure_deployment is None and client: + azure_deployment = getattr(client, "_azure_deployment", None) + if isinstance(azure_deployment, str) and azure_deployment: + resolved_azure_deployment = azure_deployment + if resolved_azure_deployment: + azure_settings["deployment_name"] = resolved_azure_deployment + client_args["azure_deployment"] = resolved_azure_deployment + else: + deployment_env_guidance = _join_env_names([ + AZURE_DEPLOYMENT_ENV_VARS[field] for field in azure_deployment_fields + ]) + has_azure_configuration = ( + client is not None + or azure_settings.get("endpoint") is not None + or azure_settings.get("base_url") is not None + ) + if checked_openai and not has_azure_configuration: + raise SettingNotFoundError( + "OpenAI credentials are required. Provide the 'api_key' parameter or set 'OPENAI_API_KEY'. " + "To use Azure OpenAI instead, pass 'azure_endpoint' or set 'AZURE_OPENAI_ENDPOINT' or " + "'AZURE_OPENAI_BASE_URL'." ) - openai_settings["api_version"] = resolved_api_version or default_azure_api_version + raise SettingNotFoundError( + "Azure OpenAI client requires a deployment name, which can be provided via the 'model' parameter, " + f"or the {deployment_env_guidance} environment variable." + ) + if client: + return azure_settings, client, True # type: ignore[return-value] + client_args["default_headers"] = merged_headers + if endpoint := azure_settings.get("endpoint"): + if responses_mode: + client_args["base_url"] = f"{endpoint.rstrip('/')}/openai/v1/" + else: + client_args["azure_endpoint"] = endpoint + if base_url := azure_settings.get("base_url"): + client_args["base_url"] = base_url + if api_key := azure_settings.get("api_key"): + client_args["api_key"] = api_key.get_secret_value() + if api_key_callable: + client_args["api_key"] = api_key_callable + if api_version := azure_settings.get("api_version"): + client_args["api_version"] = api_version + if credential: + client_args["azure_ad_token_provider"] = _resolve_azure_credential_to_token_provider(credential) + if "api_key" not in client_args and "azure_ad_token_provider" not in client_args: + raise SettingNotFoundError( + "Azure OpenAI client requires either an API key or an Azure AD token provider." + " This can be provided either as a callable api_key or via the credential parameter." + ) + return azure_settings, AsyncAzureOpenAI(**client_args), True # type: ignore[return-value] - return openai_settings, use_azure_client + +def _resolve_azure_credential_to_token_provider( + credential: AzureCredentialTypes | AzureTokenProvider, +) -> AzureTokenProvider: + """Resolve an Azure credential or token provider for Azure OpenAI auth.""" + if callable(credential): + return credential + + try: + from azure.core.credentials import TokenCredential + from azure.core.credentials_async import AsyncTokenCredential + from azure.identity import get_bearer_token_provider + from azure.identity.aio import get_bearer_token_provider as get_async_bearer_token_provider + except ModuleNotFoundError as exc: + raise ModuleNotFoundError( + "Azure credential auth requires the 'azure-identity' package. Install it with: pip install azure-identity" + ) from exc + + if isinstance(credential, AsyncTokenCredential): + return get_async_bearer_token_provider(credential, AZURE_OPENAI_TOKEN_SCOPE) + if isinstance(credential, TokenCredential): + return get_bearer_token_provider(credential, AZURE_OPENAI_TOKEN_SCOPE) # type: ignore[arg-type] + raise ValueError( + "The 'credential' parameter must be an Azure TokenCredential, AsyncTokenCredential, or a " + "callable token provider." + ) def maybe_append_azure_endpoint_guidance(message: str, *, azure_endpoint: str | None) -> str: diff --git a/python/packages/openai/tests/openai/conftest.py b/python/packages/openai/tests/openai/conftest.py index 1ef52baf81..34ea878a19 100644 --- a/python/packages/openai/tests/openai/conftest.py +++ b/python/packages/openai/tests/openai/conftest.py @@ -43,6 +43,8 @@ def openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # "OPENAI_ORG_ID", "OPENAI_MODEL", "OPENAI_EMBEDDING_MODEL", + "OPENAI_CHAT_MODEL", + "OPENAI_RESPONSES_MODEL", "OPENAI_TEXT_MODEL_ID", "OPENAI_TEXT_TO_IMAGE_MODEL_ID", "OPENAI_AUDIO_TO_TEXT_MODEL_ID", @@ -53,6 +55,9 @@ def openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # "AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_BASE_URL", "AZURE_OPENAI_API_KEY", + "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", + "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", + "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", "AZURE_OPENAI_DEPLOYMENT_NAME", "AZURE_OPENAI_API_VERSION", ], @@ -97,6 +102,8 @@ def azure_openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dic "OPENAI_ORG_ID", "OPENAI_MODEL", "OPENAI_EMBEDDING_MODEL", + "OPENAI_CHAT_MODEL", + "OPENAI_RESPONSES_MODEL", "OPENAI_TEXT_MODEL_ID", "OPENAI_TEXT_TO_IMAGE_MODEL_ID", "OPENAI_AUDIO_TO_TEXT_MODEL_ID", @@ -107,6 +114,9 @@ def azure_openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dic "AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_BASE_URL", "AZURE_OPENAI_API_KEY", + "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", + "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", + "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", "AZURE_OPENAI_DEPLOYMENT_NAME", "AZURE_OPENAI_API_VERSION", ], @@ -114,6 +124,9 @@ def azure_openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dic env_vars = { "AZURE_OPENAI_ENDPOINT": "https://test-endpoint.openai.azure.com", + "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "test_chat_deployment", + "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME": "test_responses_deployment", + "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME": "test_embedding_deployment", "AZURE_OPENAI_DEPLOYMENT_NAME": "test_deployment", "AZURE_OPENAI_API_KEY": "test_api_key", "AZURE_OPENAI_API_VERSION": "2024-12-01-preview", diff --git a/python/packages/openai/tests/openai/test_assistant_provider.py b/python/packages/openai/tests/openai/test_assistant_provider.py index c05ea950a6..df811f6c37 100644 --- a/python/packages/openai/tests/openai/test_assistant_provider.py +++ b/python/packages/openai/tests/openai/test_assistant_provider.py @@ -1,6 +1,5 @@ # Copyright (c) Microsoft. All rights reserved. -import os from typing import Annotated, Any from unittest.mock import AsyncMock, MagicMock @@ -750,64 +749,3 @@ class TestToolMerging: # endregion - -# region Integration Tests - -skip_if_openai_integration_tests_disabled = pytest.mark.skipif( - os.getenv("OPENAI_API_KEY", "") in ("", "test-dummy-key"), - reason="No real OPENAI_API_KEY provided; skipping integration tests.", -) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_openai_integration_tests_disabled -class TestOpenAIAssistantProviderIntegration: - """Integration tests requiring real OpenAI API.""" - - async def test_create_and_run_agent(self) -> None: - """End-to-end test of creating and running an agent.""" - provider = OpenAIAssistantProvider() - - agent = await provider.create_agent( - name="IntegrationTestAgent", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful assistant. Respond briefly.", - ) - - try: - result = await agent.run("Say 'hello' and nothing else.") - result_text = str(result) - assert "hello" in result_text.lower() - finally: - # Clean up the assistant - await provider._client.beta.assistants.delete(agent.id) # type: ignore[reportPrivateUsage, union-attr] - - async def test_create_agent_with_function_tools_integration(self) -> None: - """Integration test with function tools.""" - provider = OpenAIAssistantProvider() - - @tool(approval_mode="never_require") - def get_current_time() -> str: - """Get the current time.""" - from datetime import datetime - - return datetime.now().strftime("%H:%M") - - agent = await provider.create_agent( - name="TimeAgent", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful assistant.", - tools=[get_current_time], - ) - - try: - result = await agent.run("What time is it? Use the get_current_time function.") - result_text = str(result) - # The response should contain time information - assert ":" in result_text or "time" in result_text.lower() - finally: - await provider._client.beta.assistants.delete(agent.id) # type: ignore[reportPrivateUsage, union-attr] - - -# endregion diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index 897fe5f913..3c09839594 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -28,6 +28,7 @@ from agent_framework._sessions import ( from agent_framework.exceptions import ( ChatClientException, ChatClientInvalidRequestException, + SettingNotFoundError, ) from openai import BadRequestError from openai.types.responses.response_reasoning_item import Summary @@ -109,6 +110,14 @@ def test_init(openai_unit_test_env: dict[str, str]) -> None: assert isinstance(openai_responses_client, SupportsChatGetResponse) +def test_init_prefers_openai_responses_model(monkeypatch, openai_unit_test_env: dict[str, str]) -> None: + monkeypatch.setenv("OPENAI_RESPONSES_MODEL", "test_responses_model_id") + + openai_responses_client = OpenAIChatClient() + + assert openai_responses_client.model == "test_responses_model_id" + + def test_init_validation_fail() -> None: # Test successful initialization with pytest.raises(ValueError): @@ -143,7 +152,7 @@ def test_init_with_default_header(openai_unit_test_env: dict[str, str]) -> None: @pytest.mark.parametrize("exclude_list", [["OPENAI_MODEL"]], indirect=True) def test_init_with_empty_model_id(openai_unit_test_env: dict[str, str]) -> None: - with pytest.raises(ValueError): + with pytest.raises(SettingNotFoundError): OpenAIChatClient() @@ -151,7 +160,7 @@ def test_init_with_empty_model_id(openai_unit_test_env: dict[str, str]) -> None: def test_init_with_empty_api_key(openai_unit_test_env: dict[str, str]) -> None: model_id = "test_model_id" - with pytest.raises(ValueError): + with pytest.raises(SettingNotFoundError): OpenAIChatClient( model=model_id, ) @@ -203,34 +212,56 @@ async def test_get_response_with_invalid_input() -> None: async def test_get_response_with_all_parameters() -> None: - """Test get_response with all possible parameters to cover parameter handling logic.""" + """Test request preparation with a comprehensive parameter set.""" client = OpenAIChatClient(model="test-model", api_key="test-key") - # Test with comprehensive parameter set - should fail due to invalid API key - with pytest.raises(ChatClientException): - await client.get_response( - messages=[Message(role="user", text="Test message")], - options={ - "include": ["message.output_text.logprobs"], - "instructions": "You are a helpful assistant", - "max_tokens": 100, - "parallel_tool_calls": True, - "model": "gpt-4", - "previous_response_id": "prev-123", - "reasoning": {"chain_of_thought": "enabled"}, - "service_tier": "auto", - "response_format": OutputStruct, - "seed": 42, - "store": True, - "temperature": 0.7, - "tool_choice": "auto", - "tools": [get_weather], - "top_p": 0.9, - "user": "test-user", - "truncation": "auto", - "timeout": 30.0, - "additional_properties": {"custom": "value"}, - }, - ) + _, run_options, _ = await client._prepare_request( + messages=[Message(role="user", text="Test message")], + options={ + "include": ["message.output_text.logprobs"], + "instructions": "You are a helpful assistant", + "max_tokens": 100, + "parallel_tool_calls": True, + "model": "gpt-4", + "previous_response_id": "prev-123", + "reasoning": {"chain_of_thought": "enabled"}, + "service_tier": "auto", + "response_format": OutputStruct, + "seed": 42, + "store": True, + "temperature": 0.7, + "tool_choice": "auto", + "tools": [get_weather], + "top_p": 0.9, + "user": "test-user", + "truncation": "auto", + "timeout": 30.0, + "additional_properties": {"custom": "value"}, + }, + ) + + assert run_options["include"] == ["message.output_text.logprobs"] + assert run_options["max_output_tokens"] == 100 + assert run_options["parallel_tool_calls"] is True + assert run_options["model"] == "gpt-4" + assert run_options["previous_response_id"] == "prev-123" + assert run_options["reasoning"] == {"chain_of_thought": "enabled"} + assert run_options["service_tier"] == "auto" + assert run_options["text_format"] is OutputStruct + assert run_options["store"] is True + assert run_options["temperature"] == 0.7 + assert run_options["tool_choice"] == "auto" + assert run_options["top_p"] == 0.9 + assert run_options["user"] == "test-user" + assert run_options["truncation"] == "auto" + assert run_options["timeout"] == 30.0 + assert run_options["additional_properties"] == {"custom": "value"} + assert len(run_options["tools"]) == 1 + assert run_options["tools"][0]["type"] == "function" + assert run_options["tools"][0]["name"] == "get_weather" + assert run_options["input"][0]["role"] == "system" + assert run_options["input"][0]["content"][0]["text"] == "You are a helpful assistant" + assert run_options["input"][1]["role"] == "user" + assert run_options["input"][1]["content"][0]["text"] == "Test message" @pytest.mark.asyncio @@ -248,12 +279,13 @@ async def test_web_search_tool_with_location() -> None: } ) - # Should raise an authentication error due to invalid API key - with pytest.raises(ChatClientException): - await client.get_response( - messages=[Message(role="user", text="What's the weather?")], - options={"tools": [web_search_tool], "tool_choice": "auto"}, - ) + _, run_options, _ = await client._prepare_request( + messages=[Message(role="user", text="What's the weather?")], + options={"tools": [web_search_tool], "tool_choice": "auto"}, + ) + + assert run_options["tools"] == [web_search_tool] + assert run_options["tool_choice"] == "auto" async def test_code_interpreter_tool_variations() -> None: @@ -263,20 +295,22 @@ async def test_code_interpreter_tool_variations() -> None: # Test code interpreter using static method code_tool = OpenAIChatClient.get_code_interpreter_tool() - with pytest.raises(ChatClientException): - await client.get_response( - messages=[Message("user", ["Run some code"])], - options={"tools": [code_tool]}, - ) + _, run_options, _ = await client._prepare_request( + messages=[Message("user", ["Run some code"])], + options={"tools": [code_tool]}, + ) + + assert run_options["tools"] == [code_tool] # Test code interpreter with files using static method code_tool_with_files = OpenAIChatClient.get_code_interpreter_tool(file_ids=["file1", "file2"]) - with pytest.raises(ChatClientException): - await client.get_response( - messages=[Message(role="user", text="Process these files")], - options={"tools": [code_tool_with_files]}, - ) + _, run_options, _ = await client._prepare_request( + messages=[Message(role="user", text="Process these files")], + options={"tools": [code_tool_with_files]}, + ) + + assert run_options["tools"] == [code_tool_with_files] async def test_content_filter_exception() -> None: @@ -300,23 +334,23 @@ async def test_content_filter_exception() -> None: @pytest.mark.asyncio async def test_hosted_file_search_tool_validation() -> None: - """Test get_response HostedFileSearchTool validation.""" + """Test HostedFileSearchTool validation and request preparation.""" client = OpenAIChatClient(model="test-model", api_key="test-key") # Test file search tool with vector store IDs file_search_tool = OpenAIChatClient.get_file_search_tool(vector_store_ids=["vs_123"]) - # Test using file search tool - may raise various exceptions depending on API response - with pytest.raises((ValueError, ChatClientInvalidRequestException, ChatClientException)): - await client.get_response( - messages=[Message("user", ["Test"])], - options={"tools": [file_search_tool]}, - ) + _, run_options, _ = await client._prepare_request( + messages=[Message("user", ["Test"])], + options={"tools": [file_search_tool]}, + ) + + assert run_options["tools"] == [file_search_tool] async def test_chat_message_parsing_with_function_calls() -> None: - """Test get_response message preparation with function call and result content types in conversation flow.""" + """Test message preparation with function call and function result content.""" client = OpenAIChatClient(model="test-model", api_key="test-key") # Create messages with function call and result content @@ -335,9 +369,27 @@ async def test_chat_message_parsing_with_function_calls() -> None: Message(role="tool", contents=[function_result]), ] - # This should exercise the message parsing logic - will fail due to invalid API key - with pytest.raises(ChatClientException): - await client.get_response(messages=messages) + prepared_messages = client._prepare_messages_for_openai(messages) + + assert prepared_messages == [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Call a function"}], + }, + { + "call_id": "test-call-id", + "id": "fc_test-fc-id", + "type": "function_call", + "name": "test_function", + "arguments": '{"param": "value"}', + }, + { + "call_id": "test-call-id", + "type": "function_call_output", + "output": "Function executed successfully", + }, + ] async def test_response_format_parse_path() -> None: @@ -3043,8 +3095,6 @@ def test_with_callable_api_key() -> None: "option_name,option_value,needs_validation", [ # Simple ChatOptions - just verify they don't fail - param("temperature", 0.7, False, id="temperature"), - param("top_p", 0.9, False, id="top_p"), param("max_tokens", 500, False, id="max_tokens"), param("seed", 123, False, id="seed"), param("user", "test-user-id", False, id="user"), @@ -3057,7 +3107,6 @@ def test_with_callable_api_key() -> None: # OpenAIChatOptions - just verify they don't fail param("safety_identifier", "user-hash-abc123", False, id="safety_identifier"), param("truncation", "auto", False, id="truncation"), - param("top_logprobs", 5, False, id="top_logprobs"), param("prompt_cache_key", "test-cache-key", False, id="prompt_cache_key"), param("max_tool_calls", 3, False, id="max_tool_calls"), # Complex options requiring output validation @@ -3113,70 +3162,56 @@ async def test_integration_options( they don't cause failures. Options marked with needs_validation also check that the feature actually works correctly. """ - openai_responses_client = OpenAIChatClient() + client = OpenAIChatClient() # Need at least 2 iterations for tool_choice tests: one to get function call, one to get final response - openai_responses_client.function_invocation_configuration["max_iterations"] = 2 + client.function_invocation_configuration["max_iterations"] = 2 - for streaming in [False, True]: - # Prepare test message + # Prepare test message + if option_name.startswith("tools") or option_name.startswith("tool_choice"): + # Use weather-related prompt for tool tests + messages = [Message(role="user", text="What is the weather in Seattle?")] + elif option_name.startswith("response_format"): + # Use prompt that works well with structured output + messages = [Message(role="user", text="The weather in Seattle is sunny")] + messages.append(Message(role="user", text="What is the weather in Seattle?")) + else: + # Generic prompt for simple options + messages = [Message(role="user", text="Say 'Hello World' briefly.")] + + # Build options dict + options: dict[str, Any] = {option_name: option_value} + + # Add tools if testing tool_choice to avoid errors + if option_name.startswith("tool_choice"): + options["tools"] = [get_weather] + + # Test streaming mode + response = await client.get_response(stream=True, messages=messages, options=options).get_final_response() + + assert response is not None + assert isinstance(response, ChatResponse) + assert response.text is not None, f"No text in response for option '{option_name}'" + assert len(response.text) > 0, f"Empty response for option '{option_name}'" + + # Validate based on option type + if needs_validation: if option_name.startswith("tools") or option_name.startswith("tool_choice"): - # Use weather-related prompt for tool tests - messages = [Message(role="user", text="What is the weather in Seattle?")] + # Should have called the weather function + text = response.text.lower() + assert "sunny" in text or "seattle" in text, f"Tool not invoked for {option_name}" elif option_name.startswith("response_format"): - # Use prompt that works well with structured output - messages = [Message(role="user", text="The weather in Seattle is sunny")] - messages.append(Message(role="user", text="What is the weather in Seattle?")) - else: - # Generic prompt for simple options - messages = [Message(role="user", text="Say 'Hello World' briefly.")] - - # Build options dict - options: dict[str, Any] = {option_name: option_value} - - # Add tools if testing tool_choice to avoid errors - if option_name.startswith("tool_choice"): - options["tools"] = [get_weather] - - if streaming: - # Test streaming mode - response_stream = openai_responses_client.get_response( - stream=True, - messages=messages, - options=options, - ) - - response = await response_stream.get_final_response() - else: - # Test non-streaming mode - response = await openai_responses_client.get_response( - messages=messages, - options=options, - ) - - assert response is not None - assert isinstance(response, ChatResponse) - assert response.text is not None, f"No text in response for option '{option_name}'" - assert len(response.text) > 0, f"Empty response for option '{option_name}'" - - # Validate based on option type - if needs_validation: - if option_name.startswith("tools") or option_name.startswith("tool_choice"): - # Should have called the weather function - text = response.text.lower() - assert "sunny" in text or "seattle" in text, f"Tool not invoked for {option_name}" - elif option_name.startswith("response_format"): - if option_value == OutputStruct: - # Should have structured output - assert response.value is not None, "No structured output" - assert isinstance(response.value, OutputStruct) - assert "seattle" in response.value.location.lower() - else: - # Runtime JSON schema - assert response.value is None, "No structured output, can't parse any json." - response_value = json.loads(response.text) - assert isinstance(response_value, dict) - assert "location" in response_value - assert "seattle" in response_value["location"].lower() + if option_value == OutputStruct: + # Should have structured output + assert response.value is not None, "No structured output" + assert isinstance(response.value, OutputStruct) + assert "seattle" in response.value.location.lower() + else: + # Runtime JSON schema + assert response.value is None, "No structured output, can't parse any json." + response_value = json.loads(response.text) + assert isinstance(response_value, dict) + assert "location" in response_value + assert "seattle" in response_value["location"].lower() @pytest.mark.timeout(300) @@ -3186,53 +3221,24 @@ async def test_integration_options( async def test_integration_web_search() -> None: client = OpenAIChatClient(model="gpt-5") - for streaming in [False, True]: - # Use static method for web search tool - web_search_tool = OpenAIChatClient.get_web_search_tool() - content = { - "messages": [ - Message( - role="user", - text="Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.", - ) - ], - "options": { - "tool_choice": "auto", - "tools": [web_search_tool], - }, - } - if streaming: - response = await client.get_response(stream=True, **content).get_final_response() - else: - response = await client.get_response(**content) - - assert response is not None - assert isinstance(response, ChatResponse) - assert "Rumi" in response.text - assert "Mira" in response.text - assert "Zoey" in response.text - - # Test that the client will use the web search tool with location - web_search_tool_with_location = OpenAIChatClient.get_web_search_tool( - user_location={"country": "US", "city": "Seattle"}, - ) - content = { - "messages": [ - Message( - role="user", - text="What is the current weather? Do not ask for my current location.", - ) - ], - "options": { - "tool_choice": "auto", - "tools": [web_search_tool_with_location], - }, - } - if streaming: - response = await client.get_response(stream=True, **content).get_final_response() - else: - response = await client.get_response(**content) - assert response.text is not None + # Test that the client will use the web search tool with location + web_search_tool_with_location = OpenAIChatClient.get_web_search_tool( + user_location={"country": "US", "city": "Seattle"}, + ) + content = { + "messages": [ + Message( + role="user", + text="What is the current weather? Do not ask for my current location.", + ) + ], + "options": { + "tool_choice": "auto", + "tools": [web_search_tool_with_location], + }, + } + response = await client.get_response(stream=True, **content).get_final_response() + assert response.text is not None @pytest.mark.skip( @@ -3351,7 +3357,6 @@ async def test_integration_tool_rich_content_image() -> None: assert "house" in response.text.lower(), f"Model did not describe the house image. Response: {response.text}" -@pytest.mark.timeout(300) @pytest.mark.flaky @pytest.mark.integration @skip_if_openai_integration_tests_disabled @@ -3363,14 +3368,11 @@ async def test_integration_agent_replays_local_tool_history_without_stale_fc_id( async def search_hotels(city: Annotated[str, "The city to search for hotels in"]) -> str: return f"The only hotel option in {city} is {hotel_code}." - client = OpenAIChatClient() + # override with model that does not do reasoning by default + client = OpenAIChatClient(model="gpt-5.4") client.function_invocation_configuration["max_iterations"] = 2 - agent = Agent( - client=client, - tools=[search_hotels], - default_options={"store": False}, - ) + agent = Agent(client=client, tools=[search_hotels], default_options={"store": False}) session = agent.create_session() first_response = await agent.run( diff --git a/python/packages/openai/tests/openai/test_openai_chat_client_azure.py b/python/packages/openai/tests/openai/test_openai_chat_client_azure.py index 8646f2d959..918fe98767 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client_azure.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client_azure.py @@ -4,12 +4,16 @@ from __future__ import annotations import json import os +from functools import wraps from pathlib import Path from typing import Any +from unittest.mock import MagicMock, patch import pytest from agent_framework import Agent, AgentResponse, ChatResponse, Content, Message, SupportsChatGetResponse, tool -from azure.identity.aio import AzureCliCredential, get_bearer_token_provider +from agent_framework.exceptions import SettingNotFoundError +from azure.core.credentials_async import AsyncTokenCredential +from azure.identity.aio import AzureCliCredential from openai import AsyncAzureOpenAI from pydantic import BaseModel from pytest import param @@ -20,11 +24,40 @@ pytestmark = pytest.mark.azure skip_if_azure_openai_integration_tests_disabled = pytest.mark.skipif( os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.openai.azure.com") - or os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "") == "", + or ( + os.getenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", "") == "" + and os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "") == "" + ), reason="No real Azure OpenAI endpoint or responses deployment provided; skipping integration tests.", ) +def _with_azure_openai_debug() -> Any: + def decorator(func: Any) -> Any: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return await func(*args, **kwargs) + except Exception as exc: + model = os.getenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME") or os.getenv( + "AZURE_OPENAI_DEPLOYMENT_NAME", "" + ) + api_version = os.getenv("AZURE_OPENAI_API_VERSION") or "preview" + endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "") + debug_message = f"Azure OpenAI debug: endpoint={endpoint}, model={model}, api_version={api_version}" + if hasattr(exc, "add_note"): + exc.add_note(debug_message) + elif exc.args: + exc.args = (f"{exc.args[0]}\n{debug_message}", *exc.args[1:]) + else: + exc.args = (debug_message,) + raise + + return wrapper + + return decorator + + class OutputStruct(BaseModel): """A structured output for testing purposes.""" @@ -32,18 +65,6 @@ class OutputStruct(BaseModel): weather: str | None = None -def _create_azure_openai_chat_client( - *, - api_key: Any = None, -) -> OpenAIChatClient: - return OpenAIChatClient( - model=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"], - api_key=api_key or os.environ["AZURE_OPENAI_API_KEY"], - azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], - api_version=os.getenv("AZURE_OPENAI_API_VERSION"), - ) - - async def create_vector_store(client: OpenAIChatClient) -> tuple[str, Content]: """Create a vector store with sample documents for testing.""" file = await client.client.files.create( @@ -79,30 +100,117 @@ async def get_weather(location: str) -> str: def test_init_with_azure_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: - client = _create_azure_openai_chat_client() + client = OpenAIChatClient(credential=AzureCliCredential()) - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] assert isinstance(client, SupportsChatGetResponse) assert isinstance(client.client, AsyncAzureOpenAI) assert client.OTEL_PROVIDER_NAME == "azure.ai.openai" - assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] - assert client.api_version == azure_openai_unit_test_env["AZURE_OPENAI_API_VERSION"] + assert client.azure_endpoint.startswith(azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"]) def test_init_auto_detects_azure_env(azure_openai_unit_test_env: dict[str, str]) -> None: client = OpenAIChatClient() - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] assert isinstance(client.client, AsyncAzureOpenAI) assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] -@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_API_VERSION"]], indirect=True) -def test_init_uses_default_azure_api_version(azure_openai_unit_test_env: dict[str, str]) -> None: - client = _create_azure_openai_chat_client() +def test_openai_api_key_wins_over_azure_env(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + + client = OpenAIChatClient() + + assert client.model == "gpt-5" + assert not isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint is None + + +def test_api_version_alone_does_not_override_openai_api_key( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + + client = OpenAIChatClient(api_version="2024-10-21") + + assert client.model == "gpt-5" + assert not isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint is None + + +def test_explicit_credential_wins_over_openai_api_key(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + + client = OpenAIChatClient(credential=lambda: "token") + + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] + assert isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] + + +def test_init_falls_back_to_generic_azure_deployment_env( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + monkeypatch.delenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", raising=False) + + client = OpenAIChatClient() assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] - assert client.api_version == "preview" + assert isinstance(client.client, AsyncAzureOpenAI) + + +def test_init_does_not_fall_back_to_openai_responses_model_for_azure_env( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + monkeypatch.delenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("AZURE_OPENAI_DEPLOYMENT_NAME", raising=False) + monkeypatch.setenv("OPENAI_RESPONSES_MODEL", "test_responses_model") + + with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a deployment name"): + OpenAIChatClient() + + +def test_init_does_not_fall_back_to_openai_model_for_azure_env( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + monkeypatch.delenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("AZURE_OPENAI_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("OPENAI_RESPONSES_MODEL", raising=False) + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + + with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a deployment name"): + OpenAIChatClient() + + +def test_init_with_credential_wraps_async_token_credential( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + class TestAsyncTokenCredential(AsyncTokenCredential): + async def get_token(self, *scopes: str, **kwargs: object): + raise NotImplementedError + + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + credential = TestAsyncTokenCredential() + token_provider = MagicMock() + + with patch("azure.identity.aio.get_bearer_token_provider", return_value=token_provider) as mock_provider: + client = OpenAIChatClient(credential=credential) + + assert isinstance(client.client, AsyncAzureOpenAI) + mock_provider.assert_called_once_with(credential, "https://cognitiveservices.azure.com/.default") + + +@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_API_VERSION"]], indirect=True) +def test_init_uses_default_azure_api_version(azure_openai_unit_test_env: dict[str, str]) -> None: + client = OpenAIChatClient(credential=AzureCliCredential()) + + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] + assert client.api_version is not None def test_openai_base_url_wins_over_azure_aliases(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: @@ -123,8 +231,6 @@ def test_openai_base_url_wins_over_azure_aliases(monkeypatch, azure_openai_unit_ @pytest.mark.parametrize( "option_name,option_value,needs_validation", [ - param("temperature", 0.7, False, id="temperature"), - param("top_p", 0.9, False, id="top_p"), param("max_tokens", 500, False, id="max_tokens"), param("seed", 123, False, id="seed"), param("user", "test-user-id", False, id="user"), @@ -136,7 +242,6 @@ def test_openai_base_url_wins_over_azure_aliases(monkeypatch, azure_openai_unit_ param("tool_choice", "none", True, id="tool_choice_none"), param("safety_identifier", "user-hash-abc123", False, id="safety_identifier"), param("truncation", "auto", False, id="truncation"), - param("top_logprobs", 5, False, id="top_logprobs"), param("prompt_cache_key", "test-cache-key", False, id="prompt_cache_key"), param("max_tool_calls", 3, False, id="max_tool_calls"), param("tools", [get_weather], True, id="tools_function"), @@ -174,15 +279,14 @@ def test_openai_base_url_wins_over_azure_aliases(monkeypatch, azure_openai_unit_ ), ], ) +@_with_azure_openai_debug() async def test_integration_options( option_name: str, option_value: Any, needs_validation: bool, ) -> None: async with AzureCliCredential() as credential: - client = _create_azure_openai_chat_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatClient(credential=credential) client.function_invocation_configuration["max_iterations"] = 2 for streaming in [False, True]: @@ -233,64 +337,34 @@ async def test_integration_options( @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_web_search() -> None: async with AzureCliCredential() as credential: - client = _create_azure_openai_chat_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatClient(credential=credential) - for streaming in [False, True]: - content = { - "messages": [ - Message( - role="user", - text="Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.", - ) - ], - "options": { - "tool_choice": "auto", - "tools": [OpenAIChatClient.get_web_search_tool()], - }, - "stream": streaming, - } - if streaming: - response = await client.get_response(**content).get_final_response() - else: - response = await client.get_response(**content) - - assert isinstance(response, ChatResponse) - assert "Rumi" in response.text - assert "Mira" in response.text - assert "Zoey" in response.text - - content = { - "messages": [ - Message( - role="user", - text="What is the current weather? Do not ask for my current location.", - ) - ], - "options": { - "tool_choice": "auto", - "tools": [OpenAIChatClient.get_web_search_tool(user_location={"country": "US", "city": "Seattle"})], - }, - "stream": streaming, - } - if streaming: - response = await client.get_response(**content).get_final_response() - else: - response = await client.get_response(**content) - assert response.text is not None + response = await client.get_response( + messages=[ + Message( + role="user", + text="What is the current weather? Do not ask for my current location.", + ) + ], + options={ + "tools": [OpenAIChatClient.get_web_search_tool(user_location={"country": "US", "city": "Seattle"})], + }, + stream=True, + ).get_final_response() + assert isinstance(response, ChatResponse) + assert response.text is not None @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_client_file_search() -> None: async with AzureCliCredential() as credential: - client = _create_azure_openai_chat_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatClient(credential=credential) file_id, vector_store = await create_vector_store(client) try: response = await client.get_response( @@ -310,11 +384,10 @@ async def test_integration_client_file_search() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_client_file_search_streaming() -> None: async with AzureCliCredential() as credential: - client = _create_azure_openai_chat_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatClient(credential=credential) file_id, vector_store = await create_vector_store(client) try: response_stream = client.get_response( @@ -336,11 +409,10 @@ async def test_integration_client_file_search_streaming() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_client_agent_hosted_mcp_tool() -> None: async with AzureCliCredential() as credential: - client = _create_azure_openai_chat_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatClient(credential=credential) response = await client.get_response( messages=[Message(role="user", text="How to create an Azure storage account using az cli?")], options={ @@ -361,11 +433,10 @@ async def test_integration_client_agent_hosted_mcp_tool() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_client_agent_hosted_code_interpreter_tool() -> None: async with AzureCliCredential() as credential: - client = _create_azure_openai_chat_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatClient(credential=credential) response = await client.get_response( messages=[Message(role="user", text="Calculate the sum of numbers from 1 to 10 using Python code.")], @@ -381,14 +452,13 @@ async def test_integration_client_agent_hosted_code_interpreter_tool() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_integration_client_agent_existing_session() -> None: async with AzureCliCredential() as credential: preserved_session = None async with Agent( - client=_create_azure_openai_chat_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ), + client=OpenAIChatClient(credential=credential), instructions="You are a helpful assistant with good memory.", ) as first_agent: session = first_agent.create_session() @@ -403,9 +473,7 @@ async def test_integration_client_agent_existing_session() -> None: if preserved_session: async with Agent( - client=_create_azure_openai_chat_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ), + client=OpenAIChatClient(credential=credential), instructions="You are a helpful assistant with good memory.", ) as second_agent: second_response = await second_agent.run("What is my hobby?", session=preserved_session) @@ -418,6 +486,7 @@ async def test_integration_client_agent_existing_session() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_client_tool_rich_content_image() -> None: image_path = Path(__file__).parent.parent / "assets" / "sample_image.jpg" image_bytes = image_path.read_bytes() @@ -428,9 +497,7 @@ async def test_azure_openai_chat_client_tool_rich_content_image() -> None: return Content.from_data(data=image_bytes, media_type="image/jpeg") async with AzureCliCredential() as credential: - client = _create_azure_openai_chat_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatClient(credential=credential) client.function_invocation_configuration["max_iterations"] = 2 for streaming in [False, True]: diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py index 391432958f..deee60ac7a 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py @@ -13,7 +13,7 @@ from agent_framework import ( SupportsChatGetResponse, tool, ) -from agent_framework.exceptions import ChatClientException +from agent_framework.exceptions import ChatClientException, SettingNotFoundError from openai import BadRequestError from openai.types.chat.chat_completion import ChatCompletion, Choice from openai.types.chat.chat_completion_message import ChatCompletionMessage @@ -37,6 +37,14 @@ def test_init(openai_unit_test_env: dict[str, str]) -> None: assert isinstance(open_ai_chat_completion, SupportsChatGetResponse) +def test_init_prefers_openai_chat_model(monkeypatch, openai_unit_test_env: dict[str, str]) -> None: + monkeypatch.setenv("OPENAI_CHAT_MODEL", "test_chat_model_id") + + open_ai_chat_completion = OpenAIChatCompletionClient() + + assert open_ai_chat_completion.model == "test_chat_model_id" + + def test_init_validation_fail() -> None: # Test successful initialization with pytest.raises(ValueError): @@ -93,7 +101,7 @@ def test_init_base_url_from_settings_env() -> None: @pytest.mark.parametrize("exclude_list", [["OPENAI_MODEL"]], indirect=True) def test_init_with_empty_model_id(openai_unit_test_env: dict[str, str]) -> None: - with pytest.raises(ValueError): + with pytest.raises(SettingNotFoundError): OpenAIChatCompletionClient() @@ -101,7 +109,7 @@ def test_init_with_empty_model_id(openai_unit_test_env: dict[str, str]) -> None: def test_init_with_empty_api_key(openai_unit_test_env: dict[str, str]) -> None: model_id = "test_model_id" - with pytest.raises(ValueError): + with pytest.raises(SettingNotFoundError): OpenAIChatCompletionClient( model=model_id, ) @@ -1480,71 +1488,61 @@ async def test_integration_options( # Need at least 2 iterations for tool_choice tests: one to get function call, one to get final response client.function_invocation_configuration["max_iterations"] = 2 - for streaming in [False, True]: - # Prepare test message + # Prepare test message + if option_name.startswith("tools") or option_name.startswith("tool_choice"): + # Use weather-related prompt for tool tests + messages = [Message(role="user", text="What is the weather in Seattle?")] + elif option_name.startswith("response_format"): + # Use prompt that works well with structured output + messages = [Message(role="user", text="The weather in Seattle is sunny")] + messages.append(Message(role="user", text="What is the weather in Seattle?")) + else: + # Generic prompt for simple options + messages = [Message(role="user", text="Say 'Hello World' briefly.")] + + # Build options dict + options: dict[str, Any] = {option_name: option_value} + + # Add tools if testing tool_choice to avoid errors + if option_name.startswith("tool_choice"): + options["tools"] = [get_weather] + + # Test streaming mode + response = await client.get_response( + messages=messages, + stream=True, + options=options, + ).get_final_response() + + assert response is not None + assert isinstance(response, ChatResponse) + assert response.messages is not None + if not option_name.startswith("tool_choice") and ( + (isinstance(option_value, str) and option_value != "required") + or (isinstance(option_value, dict) and option_value.get("mode") != "required") + ): + assert response.text is not None, f"No text in response for option '{option_name}'" + assert len(response.text) > 0, f"Empty response for option '{option_name}'" + + # Validate based on option type + if needs_validation: if option_name.startswith("tools") or option_name.startswith("tool_choice"): - # Use weather-related prompt for tool tests - messages = [Message(role="user", text="What is the weather in Seattle?")] + # Should have called the weather function + text = response.text.lower() + assert "sunny" in text or "seattle" in text, f"Tool not invoked for {option_name}" elif option_name.startswith("response_format"): - # Use prompt that works well with structured output - messages = [Message(role="user", text="The weather in Seattle is sunny")] - messages.append(Message(role="user", text="What is the weather in Seattle?")) - else: - # Generic prompt for simple options - messages = [Message(role="user", text="Say 'Hello World' briefly.")] - - # Build options dict - options: dict[str, Any] = {option_name: option_value} - - # Add tools if testing tool_choice to avoid errors - if option_name.startswith("tool_choice"): - options["tools"] = [get_weather] - - if streaming: - # Test streaming mode - response_stream = client.get_response( - messages=messages, - stream=True, - options=options, - ) - - response = await response_stream.get_final_response() - else: - # Test non-streaming mode - response = await client.get_response( - messages=messages, - options=options, - ) - - assert response is not None - assert isinstance(response, ChatResponse) - assert response.messages is not None - if not option_name.startswith("tool_choice") and ( - (isinstance(option_value, str) and option_value != "required") - or (isinstance(option_value, dict) and option_value.get("mode") != "required") - ): - assert response.text is not None, f"No text in response for option '{option_name}'" - assert len(response.text) > 0, f"Empty response for option '{option_name}'" - - # Validate based on option type - if needs_validation: - if option_name.startswith("tools") or option_name.startswith("tool_choice"): - # Should have called the weather function - text = response.text.lower() - assert "sunny" in text or "seattle" in text, f"Tool not invoked for {option_name}" - elif option_name.startswith("response_format"): - if option_value == OutputStruct: - # Should have structured output - assert response.value is not None, "No structured output" - assert isinstance(response.value, OutputStruct) - assert "seattle" in response.value.location.lower() - else: - # Runtime JSON schema - assert response.value is None, "No structured output, can't parse any json." - response_value = json.loads(response.text) - assert isinstance(response_value, dict) - assert "location" in response_value - assert "seattle" in response_value["location"].lower() + if option_value == OutputStruct: + # Should have structured output + assert response.value is not None, "No structured output" + assert isinstance(response.value, OutputStruct) + assert "seattle" in response.value.location.lower() + else: + # Runtime JSON schema + assert response.value is None, "No structured output, can't parse any json." + response_value = json.loads(response.text) + assert isinstance(response_value, dict) + assert "location" in response_value + assert "seattle" in response_value["location"].lower() @pytest.mark.flaky diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client_azure.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client_azure.py index 148fcb68b9..f9edab227a 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client_azure.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client_azure.py @@ -3,7 +3,9 @@ from __future__ import annotations import os -from collections.abc import Awaitable, Callable +from functools import wraps +from typing import Any +from unittest.mock import MagicMock, patch import pytest from agent_framework import ( @@ -16,7 +18,9 @@ from agent_framework import ( SupportsChatGetResponse, tool, ) -from azure.identity.aio import AzureCliCredential, get_bearer_token_provider +from agent_framework.exceptions import SettingNotFoundError +from azure.core.credentials_async import AsyncTokenCredential +from azure.identity.aio import AzureCliCredential from openai import AsyncAzureOpenAI from agent_framework_openai import OpenAIChatCompletionClient @@ -25,21 +29,37 @@ pytestmark = pytest.mark.azure skip_if_azure_openai_integration_tests_disabled = pytest.mark.skipif( os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.openai.azure.com") - or os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "") == "", + or ( + os.getenv("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", "") == "" and os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "") == "" + ), reason="No real Azure OpenAI endpoint or chat deployment provided; skipping integration tests.", ) -def _create_azure_chat_completion_client( - *, - api_key: str | Callable[[], str | Awaitable[str]] | None = None, -) -> OpenAIChatCompletionClient: - return OpenAIChatCompletionClient( - model=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"], - api_key=api_key or os.environ["AZURE_OPENAI_API_KEY"], - azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], - api_version=os.getenv("AZURE_OPENAI_API_VERSION"), - ) +def _with_azure_openai_debug() -> Any: + def decorator(func: Any) -> Any: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return await func(*args, **kwargs) + except Exception as exc: + model = os.getenv("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME") or os.getenv( + "AZURE_OPENAI_DEPLOYMENT_NAME", "" + ) + api_version = os.getenv("AZURE_OPENAI_API_VERSION", "") + endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "") + debug_message = f"Azure OpenAI debug: endpoint={endpoint}, model={model}, api_version={api_version}" + if hasattr(exc, "add_note"): + exc.add_note(debug_message) + elif exc.args: + exc.args = (f"{exc.args[0]}\n{debug_message}", *exc.args[1:]) + else: + exc.args = (debug_message,) + raise + + return wrapper + + return decorator @tool(approval_mode="never_require") @@ -60,9 +80,9 @@ async def get_weather(location: str) -> str: def test_init_with_azure_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: - client = _create_azure_chat_completion_client() + client = OpenAIChatCompletionClient(azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT")) - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] assert isinstance(client, SupportsChatGetResponse) assert isinstance(client.client, AsyncAzureOpenAI) assert client.OTEL_PROVIDER_NAME == "azure.ai.openai" @@ -73,18 +93,86 @@ def test_init_with_azure_endpoint(azure_openai_unit_test_env: dict[str, str]) -> def test_init_auto_detects_azure_env(azure_openai_unit_test_env: dict[str, str]) -> None: client = OpenAIChatCompletionClient() - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] assert isinstance(client.client, AsyncAzureOpenAI) assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] -@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_API_VERSION"]], indirect=True) -def test_init_uses_default_azure_api_version(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: - monkeypatch.setenv("OPENAI_API_VERSION", "preview") - client = _create_azure_chat_completion_client() +def test_openai_api_key_wins_over_azure_env(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + + client = OpenAIChatCompletionClient() + + assert client.model == "gpt-5" + assert not isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint is None + + +def test_explicit_credential_wins_over_openai_api_key(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + + client = OpenAIChatCompletionClient(credential=lambda: "token") + + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] + assert isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] + + +def test_init_falls_back_to_generic_azure_deployment_env( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + monkeypatch.delenv("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", raising=False) + + client = OpenAIChatCompletionClient() assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] - assert client.api_version == "2024-10-21" + assert isinstance(client.client, AsyncAzureOpenAI) + + +def test_init_does_not_fall_back_to_openai_chat_model_for_azure_env( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + monkeypatch.delenv("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("AZURE_OPENAI_DEPLOYMENT_NAME", raising=False) + monkeypatch.setenv("OPENAI_CHAT_MODEL", "test_chat_model") + + with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a deployment name"): + OpenAIChatCompletionClient() + + +def test_init_does_not_fall_back_to_openai_model_for_azure_env( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + monkeypatch.delenv("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("AZURE_OPENAI_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("OPENAI_CHAT_MODEL", raising=False) + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + + with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a deployment name"): + OpenAIChatCompletionClient() + + +def test_init_with_credential_wraps_async_token_credential( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + monkeypatch.delenv("AZURE_OPENAI_API_KEY", raising=False) + + class TestAsyncTokenCredential(AsyncTokenCredential): + async def get_token(self, *scopes: str, **kwargs: object): + raise NotImplementedError + + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + credential = TestAsyncTokenCredential() + token_provider = MagicMock() + + with patch("azure.identity.aio.get_bearer_token_provider", return_value=token_provider) as mock_provider: + client = OpenAIChatCompletionClient(credential=credential) + + assert isinstance(client.client, AsyncAzureOpenAI) + mock_provider.assert_called_once_with(credential, "https://cognitiveservices.azure.com/.default") def test_openai_base_url_wins_over_azure_aliases(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: @@ -102,11 +190,10 @@ def test_openai_base_url_wins_over_azure_aliases(monkeypatch, azure_openai_unit_ @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_completion_client_response() -> None: async with AzureCliCredential() as credential: - client = _create_azure_chat_completion_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatCompletionClient(credential=credential) assert isinstance(client, SupportsChatGetResponse) messages = [ @@ -134,11 +221,10 @@ async def test_azure_openai_chat_completion_client_response() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_completion_client_response_tools() -> None: async with AzureCliCredential() as credential: - client = _create_azure_chat_completion_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatCompletionClient(credential=credential) response = await client.get_response( messages=[Message(role="user", text="who are Emily and David?")], @@ -153,11 +239,10 @@ async def test_azure_openai_chat_completion_client_response_tools() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_completion_client_streaming() -> None: async with AzureCliCredential() as credential: - client = _create_azure_chat_completion_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatCompletionClient(credential=credential) response = client.get_response( messages=[ @@ -190,11 +275,10 @@ async def test_azure_openai_chat_completion_client_streaming() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_completion_client_streaming_tools() -> None: async with AzureCliCredential() as credential: - client = _create_azure_chat_completion_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ) + client = OpenAIChatCompletionClient(credential=credential) response = client.get_response( messages=[Message(role="user", text="who are Emily and David?")], @@ -215,13 +299,12 @@ async def test_azure_openai_chat_completion_client_streaming_tools() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_completion_client_agent_basic_run() -> None: async with ( AzureCliCredential() as credential, Agent( - client=_create_azure_chat_completion_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ), + client=OpenAIChatCompletionClient(credential=credential), ) as agent, ): response = await agent.run("Please respond with exactly: 'This is a response test.'") @@ -234,20 +317,14 @@ async def test_azure_openai_chat_completion_client_agent_basic_run() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_completion_client_agent_basic_run_streaming() -> None: async with ( AzureCliCredential() as credential, - Agent( - client=_create_azure_chat_completion_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ), - ) as agent, + Agent(client=OpenAIChatCompletionClient(credential=credential)) as agent, ): full_text = "" - async for chunk in agent.run( - "Please respond with exactly: 'This is a streaming response test.'", - stream=True, - ): + async for chunk in agent.run("Please respond with exactly: 'This is a streaming response test.'", stream=True): assert isinstance(chunk, AgentResponseUpdate) if chunk.text: full_text += chunk.text @@ -258,13 +335,12 @@ async def test_azure_openai_chat_completion_client_agent_basic_run_streaming() - @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_completion_client_agent_session_persistence() -> None: async with ( AzureCliCredential() as credential, Agent( - client=_create_azure_chat_completion_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ), + client=OpenAIChatCompletionClient(credential=credential), instructions="You are a helpful assistant with good memory.", ) as agent, ): @@ -281,14 +357,13 @@ async def test_azure_openai_chat_completion_client_agent_session_persistence() - @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_openai_chat_completion_client_agent_existing_session() -> None: async with AzureCliCredential() as credential: preserved_session = None async with Agent( - client=_create_azure_chat_completion_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ), + client=OpenAIChatCompletionClient(credential=credential), instructions="You are a helpful assistant with good memory.", ) as first_agent: session = first_agent.create_session() @@ -299,9 +374,7 @@ async def test_azure_openai_chat_completion_client_agent_existing_session() -> N if preserved_session: async with Agent( - client=_create_azure_chat_completion_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ), + client=OpenAIChatCompletionClient(credential=credential), instructions="You are a helpful assistant with good memory.", ) as second_agent: second_response = await second_agent.run("What is my name?", session=preserved_session) @@ -314,13 +387,12 @@ async def test_azure_openai_chat_completion_client_agent_existing_session() -> N @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() async def test_azure_chat_completion_client_agent_level_tool_persistence() -> None: async with ( AzureCliCredential() as credential, Agent( - client=_create_azure_chat_completion_client( - api_key=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default") - ), + client=OpenAIChatCompletionClient(credential=credential), instructions="You are a helpful assistant that uses available tools.", tools=[get_weather], ) as agent, diff --git a/python/packages/openai/tests/openai/test_openai_embedding_client.py b/python/packages/openai/tests/openai/test_openai_embedding_client.py index 7117040ffc..4ef39697d6 100644 --- a/python/packages/openai/tests/openai/test_openai_embedding_client.py +++ b/python/packages/openai/tests/openai/test_openai_embedding_client.py @@ -6,6 +6,7 @@ import os from unittest.mock import AsyncMock, MagicMock import pytest +from agent_framework.exceptions import SettingNotFoundError from openai.types import CreateEmbeddingResponse from openai.types import Embedding as OpenAIEmbedding from openai.types.create_embedding_response import Usage @@ -32,13 +33,6 @@ def _make_openai_response( ) -@pytest.fixture -def openai_unit_test_env(monkeypatch: pytest.MonkeyPatch) -> None: - """Set up environment variables for OpenAI embedding client.""" - monkeypatch.setenv("OPENAI_API_KEY", "test-api-key") - monkeypatch.setenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small") - - # --- OpenAI unit tests --- @@ -50,24 +44,39 @@ def test_openai_construction_with_explicit_params() -> None: assert client.model == "text-embedding-3-small" -def test_openai_construction_from_env(openai_unit_test_env: None) -> None: +def test_openai_construction_from_env(openai_unit_test_env: dict[str, str]) -> None: client = OpenAIEmbeddingClient() + assert client.model == openai_unit_test_env["OPENAI_EMBEDDING_MODEL"] + + +def test_with_callable_api_key() -> None: + """Test OpenAIEmbeddingClient initialization with callable API key.""" + + async def get_api_key() -> str: + return "test-api-key-123" + + client = OpenAIEmbeddingClient(model="text-embedding-3-small", api_key=get_api_key) + assert client.model == "text-embedding-3-small" + assert client.client is not None -def test_openai_construction_missing_api_key_raises(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("OPENAI_API_KEY", raising=False) - with pytest.raises(ValueError, match="API key is required"): +@pytest.mark.parametrize("exclude_list", [["OPENAI_API_KEY"]], indirect=True) +def test_openai_construction_without_openai_or_azure_config_raises_clear_error( + openai_unit_test_env: dict[str, str], +) -> None: + with pytest.raises(SettingNotFoundError): OpenAIEmbeddingClient(model="text-embedding-3-small") -def test_openai_construction_missing_model_raises(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("OPENAI_EMBEDDING_MODEL", raising=False) - with pytest.raises(ValueError, match="embedding model is required"): - OpenAIEmbeddingClient(api_key="test-key") +@pytest.mark.parametrize("exclude_list", [["OPENAI_EMBEDDING_MODEL"]], indirect=True) +def test_openai_construction_falls_back_to_openai_model(openai_unit_test_env: dict[str, str]) -> None: + client = OpenAIEmbeddingClient() + + assert client.model == openai_unit_test_env["OPENAI_MODEL"] -async def test_openai_get_embeddings(openai_unit_test_env: None) -> None: +async def test_openai_get_embeddings(openai_unit_test_env: dict[str, str]) -> None: mock_response = _make_openai_response( embeddings=[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]], ) @@ -85,7 +94,7 @@ async def test_openai_get_embeddings(openai_unit_test_env: None) -> None: assert result[0].dimensions == 3 -async def test_openai_get_embeddings_usage(openai_unit_test_env: None) -> None: +async def test_openai_get_embeddings_usage(openai_unit_test_env: dict[str, str]) -> None: mock_response = _make_openai_response( embeddings=[[0.1]], prompt_tokens=10, @@ -103,7 +112,7 @@ async def test_openai_get_embeddings_usage(openai_unit_test_env: None) -> None: assert result.usage["total_token_count"] == 10 -async def test_openai_options_passthrough_dimensions(openai_unit_test_env: None) -> None: +async def test_openai_options_passthrough_dimensions(openai_unit_test_env: dict[str, str]) -> None: mock_response = _make_openai_response(embeddings=[[0.1]]) client = OpenAIEmbeddingClient() client.client = MagicMock() @@ -118,7 +127,7 @@ async def test_openai_options_passthrough_dimensions(openai_unit_test_env: None) assert result.options is options -async def test_openai_options_passthrough_encoding_format(openai_unit_test_env: None) -> None: +async def test_openai_options_passthrough_encoding_format(openai_unit_test_env: dict[str, str]) -> None: mock_response = _make_openai_response(embeddings=[[0.1]]) client = OpenAIEmbeddingClient() client.client = MagicMock() @@ -132,7 +141,7 @@ async def test_openai_options_passthrough_encoding_format(openai_unit_test_env: assert call_kwargs["encoding_format"] == "base64" -async def test_openai_base64_decoding(openai_unit_test_env: None) -> None: +async def test_openai_base64_decoding(openai_unit_test_env: dict[str, str]) -> None: import base64 import struct @@ -176,7 +185,7 @@ async def test_openai_error_when_no_model_id() -> None: await client.get_embeddings(["test"]) -async def test_openai_empty_values_returns_empty(openai_unit_test_env: None) -> None: +async def test_openai_empty_values_returns_empty(openai_unit_test_env: dict[str, str]) -> None: client = OpenAIEmbeddingClient() client.client = MagicMock() client.client.embeddings = MagicMock() diff --git a/python/packages/openai/tests/openai/test_openai_embedding_client_azure.py b/python/packages/openai/tests/openai/test_openai_embedding_client_azure.py new file mode 100644 index 0000000000..3cf62a064d --- /dev/null +++ b/python/packages/openai/tests/openai/test_openai_embedding_client_azure.py @@ -0,0 +1,250 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import os +from functools import wraps +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from agent_framework.exceptions import SettingNotFoundError +from azure.core.credentials_async import AsyncTokenCredential +from azure.identity.aio import AzureCliCredential +from openai import AsyncAzureOpenAI + +from agent_framework_openai import OpenAIEmbeddingClient, OpenAIEmbeddingOptions + +pytestmark = pytest.mark.azure + +skip_if_azure_openai_integration_tests_disabled = pytest.mark.skipif( + os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.openai.azure.com") + or ( + os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", "") == "" + and os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "") == "" + ), + reason="No real Azure OpenAI endpoint or embedding deployment provided; skipping integration tests.", +) + + +def _with_azure_openai_debug() -> Any: + def decorator(func: Any) -> Any: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return await func(*args, **kwargs) + except Exception as exc: + model = os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") or os.getenv( + "AZURE_OPENAI_DEPLOYMENT_NAME", "" + ) + api_version = os.getenv("AZURE_OPENAI_API_VERSION", "") + endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "") + debug_message = f"Azure OpenAI debug: endpoint={endpoint}, model={model}, api_version={api_version}" + if hasattr(exc, "add_note"): + exc.add_note(debug_message) + elif exc.args: + exc.args = (f"{exc.args[0]}\n{debug_message}", *exc.args[1:]) + else: + exc.args = (debug_message,) + raise + + return wrapper + + return decorator + + +def _get_azure_embedding_deployment_name() -> str: + return os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") or os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"] + + +def _create_azure_embedding_client( + *, + api_key: str | None = None, + credential: AsyncTokenCredential | None = None, +) -> OpenAIEmbeddingClient: + resolved_api_key = ( + api_key if api_key is not None else None if credential is not None else os.environ["AZURE_OPENAI_API_KEY"] + ) + return OpenAIEmbeddingClient( + model=_get_azure_embedding_deployment_name(), + api_key=resolved_api_key, + azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], + api_version=os.getenv("AZURE_OPENAI_API_VERSION"), + credential=credential, + ) + + +def test_init_with_azure_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: + client = _create_azure_embedding_client() + + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"] + assert isinstance(client.client, AsyncAzureOpenAI) + assert client.OTEL_PROVIDER_NAME == "azure.ai.openai" + assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] + assert client.api_version == azure_openai_unit_test_env["AZURE_OPENAI_API_VERSION"] + + +def test_init_auto_detects_azure_embedding_env(azure_openai_unit_test_env: dict[str, str]) -> None: + client = OpenAIEmbeddingClient() + + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"] + assert isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] + + +def test_init_falls_back_to_generic_azure_deployment_env( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + monkeypatch.delenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", raising=False) + + client = OpenAIEmbeddingClient() + + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_DEPLOYMENT_NAME"] + assert isinstance(client.client, AsyncAzureOpenAI) + + +def test_init_does_not_fall_back_to_openai_embedding_model_for_azure_env( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + monkeypatch.delenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("AZURE_OPENAI_DEPLOYMENT_NAME", raising=False) + monkeypatch.setenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small") + + with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a deployment name"): + OpenAIEmbeddingClient() + + +def test_init_does_not_fall_back_to_openai_model_for_azure_env( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + monkeypatch.delenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("AZURE_OPENAI_DEPLOYMENT_NAME", raising=False) + monkeypatch.delenv("OPENAI_EMBEDDING_MODEL", raising=False) + monkeypatch.setenv("OPENAI_MODEL", "gpt-5") + + with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a deployment name"): + OpenAIEmbeddingClient() + + +def test_openai_api_key_wins_over_azure_env(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small") + + client = OpenAIEmbeddingClient() + + assert client.model == "text-embedding-3-small" + assert not isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint is None + + +def test_api_version_alone_does_not_override_openai_api_key( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small") + + client = OpenAIEmbeddingClient(api_version="2024-10-21") + + assert client.model == "text-embedding-3-small" + assert not isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint is None + + +def test_explicit_credential_wins_over_openai_api_key(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small") + + client = OpenAIEmbeddingClient(credential=lambda: "token") + + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"] + assert isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"] + + +def test_init_with_credential_wraps_async_token_credential( + monkeypatch, azure_openai_unit_test_env: dict[str, str] +) -> None: + class TestAsyncTokenCredential(AsyncTokenCredential): + async def get_token(self, *scopes: str, **kwargs: object): + raise NotImplementedError + + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small") + credential = TestAsyncTokenCredential() + token_provider = MagicMock() + + with patch("azure.identity.aio.get_bearer_token_provider", return_value=token_provider) as mock_provider: + client = OpenAIEmbeddingClient(credential=credential) + + assert isinstance(client.client, AsyncAzureOpenAI) + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"] + mock_provider.assert_called_once_with(credential, "https://cognitiveservices.azure.com/.default") + + +@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_API_VERSION"]], indirect=True) +def test_init_uses_default_azure_api_version(azure_openai_unit_test_env: dict[str, str]) -> None: + client = _create_azure_embedding_client() + + assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"] + assert client.api_version == "2024-10-21" + + +def test_openai_base_url_wins_over_azure_aliases(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key") + monkeypatch.setenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small") + monkeypatch.setenv("OPENAI_BASE_URL", "https://custom-openai-endpoint.com/v1") + + client = OpenAIEmbeddingClient() + + assert client.model == "text-embedding-3-small" + assert not isinstance(client.client, AsyncAzureOpenAI) + assert client.azure_endpoint is None + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() +async def test_azure_openai_get_embeddings() -> None: + async with AzureCliCredential() as credential: + client = _create_azure_embedding_client(credential=credential) + + result = await client.get_embeddings(["hello world"]) + + assert len(result) == 1 + assert isinstance(result[0].vector, list) + assert len(result[0].vector) > 0 + assert all(isinstance(v, float) for v in result[0].vector) + assert result[0].model is not None + assert result.usage is not None + assert result.usage["input_token_count"] > 0 + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() +async def test_azure_openai_get_embeddings_multiple() -> None: + async with AzureCliCredential() as credential: + client = _create_azure_embedding_client(credential=credential) + + result = await client.get_embeddings(["hello", "world", "test"]) + + assert len(result) == 3 + dims = [len(embedding.vector) for embedding in result] + assert all(dimension == dims[0] for dimension in dims) + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_azure_openai_integration_tests_disabled +@_with_azure_openai_debug() +async def test_azure_openai_get_embeddings_with_dimensions() -> None: + async with AzureCliCredential() as credential: + client = _create_azure_embedding_client(credential=credential) + + options: OpenAIEmbeddingOptions = {"dimensions": 256} + result = await client.get_embeddings(["hello world"], options=options) + + assert len(result) == 1 + assert len(result[0].vector) == 256 diff --git a/python/packages/openai/tests/openai/test_openai_shared.py b/python/packages/openai/tests/openai/test_openai_shared.py new file mode 100644 index 0000000000..b69feb7314 --- /dev/null +++ b/python/packages/openai/tests/openai/test_openai_shared.py @@ -0,0 +1,54 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from azure.core.credentials import TokenCredential +from azure.core.credentials_async import AsyncTokenCredential + +from agent_framework_openai._shared import AZURE_OPENAI_TOKEN_SCOPE, _resolve_azure_credential_to_token_provider + + +class _AsyncTokenCredentialStub(AsyncTokenCredential): + async def get_token(self, *scopes: str, **kwargs: object): + raise NotImplementedError + + +class _TokenCredentialStub(TokenCredential): + def get_token(self, *scopes: str, **kwargs: object): + raise NotImplementedError + + +def test_resolve_azure_async_credential_wraps_provider() -> None: + credential = _AsyncTokenCredentialStub() + token_provider = MagicMock() + + with patch("azure.identity.aio.get_bearer_token_provider", return_value=token_provider) as mock_provider: + resolved = _resolve_azure_credential_to_token_provider(credential) + + assert resolved is token_provider + mock_provider.assert_called_once_with(credential, AZURE_OPENAI_TOKEN_SCOPE) + + +def test_resolve_azure_sync_credential_wraps_provider() -> None: + credential = _TokenCredentialStub() + token_provider = MagicMock() + + with patch("azure.identity.get_bearer_token_provider", return_value=token_provider) as mock_provider: + resolved = _resolve_azure_credential_to_token_provider(credential) + + assert resolved is token_provider + mock_provider.assert_called_once_with(credential, AZURE_OPENAI_TOKEN_SCOPE) + + +def test_resolve_azure_callable_token_provider_passthrough() -> None: + token_provider = MagicMock() + + assert _resolve_azure_credential_to_token_provider(token_provider) is token_provider + + +def test_resolve_azure_invalid_credential_raises() -> None: + with pytest.raises(ValueError, match="credential"): + _resolve_azure_credential_to_token_provider(object()) # type: ignore[arg-type] diff --git a/python/samples/02-agents/chat_client/README.md b/python/samples/02-agents/chat_client/README.md index e03d532812..6650e510a9 100644 --- a/python/samples/02-agents/chat_client/README.md +++ b/python/samples/02-agents/chat_client/README.md @@ -57,8 +57,8 @@ Depending on the selected client, set the appropriate environment variables: **For OpenAI clients:** - `OPENAI_API_KEY`: Your OpenAI API key -- `OPENAI_CHAT_MODEL_ID`: The OpenAI model for `openai_chat` and `openai_assistants` -- `OPENAI_RESPONSES_MODEL_ID`: The OpenAI model for `openai_responses` +- `OPENAI_CHAT_MODEL`: The OpenAI model for `openai_chat` and `openai_assistants` +- `OPENAI_RESPONSES_MODEL`: The OpenAI model for `openai_responses` **For Anthropic client (`anthropic`):** - `ANTHROPIC_API_KEY`: Your Anthropic API key diff --git a/python/samples/02-agents/context_providers/azure_ai_search/search_context_semantic.py b/python/samples/02-agents/context_providers/azure_ai_search/search_context_semantic.py index 8cd8947ef6..98b7d66f88 100644 --- a/python/samples/02-agents/context_providers/azure_ai_search/search_context_semantic.py +++ b/python/samples/02-agents/context_providers/azure_ai_search/search_context_semantic.py @@ -4,8 +4,9 @@ import asyncio import os from agent_framework import Agent -from agent_framework.azure import AzureAISearchContextProvider, AzureOpenAIEmbeddingClient +from agent_framework.azure import AzureAISearchContextProvider from agent_framework.foundry import FoundryChatClient +from agent_framework.openai import OpenAIEmbeddingClient from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv @@ -31,8 +32,8 @@ Prerequisites: - AZURE_SEARCH_INDEX_NAME: Your search index name - FOUNDRY_PROJECT_ENDPOINT: Your Azure AI Foundry project endpoint - AZURE_AI_MODEL_DEPLOYMENT_NAME: Your model deployment name (e.g., "gpt-4o") - - AZURE_OPENAI_EMBEDDING_MODEL_ID: (Optional) Your embedding model for hybrid search (e.g., "text-embedding-3-small") - - AZURE_OPENAI_ENDPOINT: (Optional) Your Azure OpenAI resource URL, required if using an OpenAI embedding model for hybrid search + - AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: (Optional) Your Azure OpenAI embedding deployment for hybrid search + - AZURE_OPENAI_ENDPOINT: (Optional) Your Azure OpenAI resource URL, required if using Azure OpenAI embeddings """ # Sample queries to demonstrate RAG @@ -55,13 +56,13 @@ async def main() -> None: project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] model_deployment = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o") openai_endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT") - embedding_model = os.environ.get("AZURE_OPENAI_EMBEDDING_MODEL_ID", "text-embedding-3-small") + embedding_deployment = os.environ.get("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") embedding_client = None - if openai_endpoint and embedding_model: - embedding_client = AzureOpenAIEmbeddingClient( - endpoint=openai_endpoint, - model=embedding_model, + if openai_endpoint and embedding_deployment: + embedding_client = OpenAIEmbeddingClient( + azure_endpoint=openai_endpoint, + model=embedding_deployment, credential=credential, ) diff --git a/python/samples/02-agents/devui/README.md b/python/samples/02-agents/devui/README.md index 2bdd6d2233..c5ce2095b8 100644 --- a/python/samples/02-agents/devui/README.md +++ b/python/samples/02-agents/devui/README.md @@ -85,7 +85,7 @@ Alternatively, set environment variables globally: ```bash export OPENAI_API_KEY="your-key-here" -export OPENAI_CHAT_MODEL_ID="gpt-4o" +export OPENAI_CHAT_MODEL="gpt-4o" ``` ## Using DevUI with Your Own Agents diff --git a/python/samples/02-agents/embeddings/azure_openai_embeddings.py b/python/samples/02-agents/embeddings/azure_openai_embeddings.py index 16669eb51f..460bbcf7de 100644 --- a/python/samples/02-agents/embeddings/azure_openai_embeddings.py +++ b/python/samples/02-agents/embeddings/azure_openai_embeddings.py @@ -2,55 +2,59 @@ # Run with: uv run samples/02-agents/embeddings/azure_openai_embeddings.py - import asyncio +import os -from agent_framework.azure import AzureOpenAIEmbeddingClient +from agent_framework.openai import OpenAIEmbeddingClient +from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv -load_dotenv() - -"""Azure OpenAI Embedding Client Example - -This sample demonstrates how to generate embeddings using the Azure OpenAI embedding client. -It supports both API key and Azure credential authentication. +"""This sample demonstrates Azure OpenAI embedding generation with ``OpenAIEmbeddingClient``. Prerequisites: - Set the following environment variables or add them to a .env file: - - AZURE_OPENAI_ENDPOINT: Your Azure OpenAI endpoint URL - - AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: The embedding model deployment name - - AZURE_OPENAI_API_KEY: Your API key (or use Azure credential instead) + Set the following environment variables or add them to a local ``.env`` file: + - ``AZURE_OPENAI_ENDPOINT``: Your Azure OpenAI endpoint URL + - ``AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME``: The embedding deployment name + - ``AZURE_OPENAI_API_VERSION``: Optional API version override + + Sign in with ``az login`` before running the sample. """ +load_dotenv() + async def main() -> None: """Generate embeddings with Azure OpenAI.""" - # 1. Create a client using environment variables. - # Reads AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME, - # and AZURE_OPENAI_API_KEY from environment. - client = AzureOpenAIEmbeddingClient() + async with AzureCliCredential() as credential: + client = OpenAIEmbeddingClient( + model=os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME"), + azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), + api_version=os.getenv("AZURE_OPENAI_API_VERSION"), + credential=credential, + ) - # 2. Generate a single embedding. - result = await client.get_embeddings(["Hello, world!"]) - print(f"Single embedding dimensions: {result[0].dimensions}") - print(f"First 5 values: {result[0].vector[:5]}") - print(f"Model: {result[0].model_id}") - print(f"Usage: {result.usage}") - print() + # 1. Generate a single embedding. + result = await client.get_embeddings(["Hello, world!"]) + print(f"Single embedding dimensions: {result[0].dimensions}") + print(f"First 5 values: {result[0].vector[:5]}") + print(f"Model: {result[0].model}") + print(f"Usage: {result.usage}") + print() - # 3. Generate embeddings for multiple inputs. - texts = [ - "The weather is sunny today.", - "It is raining outside.", - "Machine learning is fascinating.", - ] - result = await client.get_embeddings(texts) - print(f"Batch of {len(result)} embeddings, each with {result[0].dimensions} dimensions") - print() + # 2. Generate embeddings for multiple inputs. + texts = [ + "The weather is sunny today.", + "It is raining outside.", + "Machine learning is fascinating.", + ] + result = await client.get_embeddings(texts) + print(f"Batch of {len(result)} embeddings, each with {result[0].dimensions} dimensions") + print(f"First embedding vector: {result[0].vector[:5]}") + print() - # 4. Generate embeddings with custom dimensions. - result = await client.get_embeddings(["Custom dimensions example"], options={"dimensions": 256}) - print(f"Custom dimensions: {result[0].dimensions}") + # 3. Generate embeddings with custom dimensions. + result = await client.get_embeddings(["Custom dimensions example"], options={"dimensions": 256}) + print(f"Custom dimensions: {result[0].dimensions}") if __name__ == "__main__": diff --git a/python/samples/02-agents/embeddings/openai_embeddings.py b/python/samples/02-agents/embeddings/openai_embeddings.py index 001b6593f5..d49b3034dd 100644 --- a/python/samples/02-agents/embeddings/openai_embeddings.py +++ b/python/samples/02-agents/embeddings/openai_embeddings.py @@ -3,31 +3,32 @@ # Run with: uv run samples/02-agents/embeddings/openai_embeddings.py import asyncio +import os from agent_framework.openai import OpenAIEmbeddingClient from dotenv import load_dotenv -load_dotenv() - -"""OpenAI Embedding Client Example - -This sample demonstrates how to generate embeddings using the OpenAI embedding client. -It shows single and batch embedding generation, as well as custom dimensions. +"""This sample demonstrates OpenAI embedding generation with explicit constructor settings. Prerequisites: - Set the OPENAI_API_KEY environment variable or add it to a .env file. + Set ``OPENAI_API_KEY`` in your environment or in a local ``.env`` file. """ +load_dotenv() + async def main() -> None: """Generate embeddings with OpenAI.""" - client = OpenAIEmbeddingClient(model="text-embedding-3-small") + client = OpenAIEmbeddingClient( + model="text-embedding-3-small", + api_key=os.getenv("OPENAI_API_KEY"), + ) # 1. Generate a single embedding. result = await client.get_embeddings(["Hello, world!"]) print(f"Single embedding dimensions: {result[0].dimensions}") print(f"First 5 values: {result[0].vector[:5]}") - print(f"Model: {result[0].model_id}") + print(f"Model: {result[0].model}") print(f"Usage: {result.usage}") print() @@ -39,7 +40,7 @@ async def main() -> None: ] result = await client.get_embeddings(texts) print(f"Batch of {len(result)} embeddings, each with {result[0].dimensions} dimensions") - print(f"First embedding vector: {result[0].vector[:5]}") # Print first 5 values of the first embedding + print(f"First embedding vector: {result[0].vector[:5]}") print() # 3. Generate embeddings with custom dimensions. diff --git a/python/samples/02-agents/mcp/README.md b/python/samples/02-agents/mcp/README.md index 1df1a449b6..e07d63ddbd 100644 --- a/python/samples/02-agents/mcp/README.md +++ b/python/samples/02-agents/mcp/README.md @@ -17,7 +17,7 @@ The Model Context Protocol (MCP) is an open standard for connecting AI agents to ## Prerequisites - `OPENAI_API_KEY` environment variable -- `OPENAI_RESPONSES_MODEL_ID` environment variable +- `OPENAI_RESPONSES_MODEL` environment variable For `mcp_github_pat.py`: - `GITHUB_PAT` - Your GitHub Personal Access Token (create at https://github.com/settings/tokens) diff --git a/python/samples/02-agents/middleware/README.md b/python/samples/02-agents/middleware/README.md index 754f96e815..5bd318575c 100644 --- a/python/samples/02-agents/middleware/README.md +++ b/python/samples/02-agents/middleware/README.md @@ -25,7 +25,7 @@ The new usage tracking sample uses `OpenAIResponsesClient`, so set the usual Ope ```bash export OPENAI_API_KEY="your-openai-api-key" -export OPENAI_RESPONSES_MODEL_ID="gpt-4.1-mini" +export OPENAI_RESPONSES_MODEL="gpt-4.1-mini" ``` Then run: diff --git a/python/samples/02-agents/observability/.env.example b/python/samples/02-agents/observability/.env.example index 11f0a07810..c1c24a5a72 100644 --- a/python/samples/02-agents/observability/.env.example +++ b/python/samples/02-agents/observability/.env.example @@ -40,8 +40,8 @@ ENABLE_SENSITIVE_DATA=true # OpenAI specific variables # ========================== OPENAI_API_KEY="..." -OPENAI_RESPONSES_MODEL_ID="gpt-4o-2024-08-06" -OPENAI_CHAT_MODEL_ID="gpt-4o-2024-08-06" +OPENAI_RESPONSES_MODEL="gpt-4o-2024-08-06" +OPENAI_CHAT_MODEL="gpt-4o-2024-08-06" # Azure AI Foundry specific variables # ==================================== diff --git a/python/samples/02-agents/providers/azure/README.md b/python/samples/02-agents/providers/azure/README.md index 1e06bda482..cd34fe2717 100644 --- a/python/samples/02-agents/providers/azure/README.md +++ b/python/samples/02-agents/providers/azure/README.md @@ -1,12 +1,48 @@ # Azure Provider Samples -This folder contains Azure OpenAI chat completion samples for Agent Framework. +This folder contains Azure-backed samples for the generic OpenAI clients in +`agent_framework.openai`. -## Azure OpenAI ChatCompletionClient Samples +## Chat Completions API samples (`OpenAIChatCompletionClient`) | File | Description | |------|-------------| -| [`openai_chat_completion_client_azure_basic.py`](openai_chat_completion_client_azure_basic.py) | Azure OpenAI Chat Client Basic Example | -| [`openai_chat_completion_client_azure_with_explicit_settings.py`](openai_chat_completion_client_azure_with_explicit_settings.py) | Azure OpenAI Chat Client with Explicit Settings Example | -| [`openai_chat_completion_client_azure_with_function_tools.py`](openai_chat_completion_client_azure_with_function_tools.py) | Azure OpenAI Chat Client with Function Tools Example | -| [`openai_chat_completion_client_azure_with_session.py`](openai_chat_completion_client_azure_with_session.py) | Azure OpenAI Chat Client with Session Management Example | +| [`openai_chat_completion_client_basic.py`](openai_chat_completion_client_basic.py) | Basic Azure chat completions sample using explicit Azure settings and `credential=AzureCliCredential()`. | +| [`openai_chat_completion_client_with_explicit_settings.py`](openai_chat_completion_client_with_explicit_settings.py) | Azure chat completions sample with explicit settings. | +| [`openai_chat_completion_client_with_function_tools.py`](openai_chat_completion_client_with_function_tools.py) | Azure chat completions sample with function tools. | +| [`openai_chat_completion_client_with_session.py`](openai_chat_completion_client_with_session.py) | Azure chat completions sample with session management. | + +## Responses API samples (`OpenAIChatClient`) + +| File | Description | +|------|-------------| +| [`openai_client_basic.py`](openai_client_basic.py) | Basic Azure responses sample using explicit settings and `credential=AzureCliCredential()`. | +| [`openai_client_with_function_tools.py`](openai_client_with_function_tools.py) | Azure responses sample with function tools. | +| [`openai_client_with_session.py`](openai_client_with_session.py) | Azure responses sample with session management. | +| [`openai_client_with_structured_output.py`](openai_client_with_structured_output.py) | Azure responses sample with structured output. | + +## Environment Variables + +Set these before running the Azure provider samples: + +- `AZURE_OPENAI_ENDPOINT` +- `AZURE_OPENAI_DEPLOYMENT_NAME` + +Optionally, you can also set: + +- `AZURE_OPENAI_API_KEY` +- `AZURE_OPENAI_API_VERSION` +- `AZURE_OPENAI_BASE_URL` + +These Azure samples are written around explicit Azure inputs such as +`credential=AzureCliCredential()`, so they stay on Azure even if `OPENAI_API_KEY` is also present. + +## Optional Dependencies + +Credential-based samples require `azure-identity`: + +```bash +pip install azure-identity +``` + +Run `az login` before executing the credential-based samples. diff --git a/python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_basic.py b/python/samples/02-agents/providers/azure/openai_chat_completion_client_basic.py similarity index 70% rename from python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_basic.py rename to python/samples/02-agents/providers/azure/openai_chat_completion_client_basic.py index db5740cfc3..030828da89 100644 --- a/python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_basic.py +++ b/python/samples/02-agents/providers/azure/openai_chat_completion_client_basic.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os from random import randint from typing import Annotated @@ -16,14 +17,12 @@ load_dotenv() """ Azure OpenAI Chat Client Basic Example -This sample demonstrates basic usage of OpenAIChatCompletionClient for direct chat-based -interactions, showing both streaming and non-streaming responses. +This sample demonstrates basic usage of OpenAIChatCompletionClient with explicit Azure +settings and a credential, showing both streaming and non-streaming responses. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; -# see samples/02-agents/tools/function_tool_with_approval.py -# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -37,11 +36,14 @@ async def non_streaming_example() -> None: """Example of non-streaming response (get the complete result at once).""" print("=== Non-streaming Response Example ===") - # Create agent with Azure Chat Client - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. agent = Agent( - client=OpenAIChatCompletionClient(credential=AzureCliCredential()), + client=OpenAIChatCompletionClient( + model=os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME"), + azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), + api_version=os.getenv("AZURE_OPENAI_API_VERSION"), + credential=AzureCliCredential(), + ), + name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -56,11 +58,14 @@ async def streaming_example() -> None: """Example of streaming response (get results as they are generated).""" print("=== Streaming Response Example ===") - # Create agent with Azure Chat Client - # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred - # authentication option. agent = Agent( - client=OpenAIChatCompletionClient(credential=AzureCliCredential()), + client=OpenAIChatCompletionClient( + model=os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME"), + azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), + api_version=os.getenv("AZURE_OPENAI_API_VERSION"), + credential=AzureCliCredential(), + ), + name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -75,7 +80,7 @@ async def streaming_example() -> None: async def main() -> None: - print("=== Basic Azure Chat Client Agent Example ===") + print("=== Basic Azure Chat Completion Client Agent Example ===") await non_streaming_example() await streaming_example() diff --git a/python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_with_explicit_settings.py b/python/samples/02-agents/providers/azure/openai_chat_completion_client_with_explicit_settings.py similarity index 71% rename from python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_with_explicit_settings.py rename to python/samples/02-agents/providers/azure/openai_chat_completion_client_with_explicit_settings.py index 16ecc8a091..cf26f6ba38 100644 --- a/python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_with_explicit_settings.py +++ b/python/samples/02-agents/providers/azure/openai_chat_completion_client_with_explicit_settings.py @@ -15,16 +15,16 @@ from pydantic import Field load_dotenv() """ -Azure OpenAI Chat Client with Explicit Settings Example +OpenAI Chat Completion Client with Explicit Settings Example -This sample demonstrates creating Azure OpenAI Chat Client with explicit configuration +This samples connects to Azure OpenAI. + +This sample demonstrates creating OpenAI Chat Completion Client with explicit configuration settings rather than relying on environment variable defaults. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; -# see samples/02-agents/tools/function_tool_with_approval.py -# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production. @tool(approval_mode="never_require") def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -39,13 +39,12 @@ async def main() -> None: # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. - _client = OpenAIChatCompletionClient( - model=os.environ["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], - endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], - credential=AzureCliCredential(), - ) agent = Agent( - client=_client, + client=OpenAIChatCompletionClient( + model=os.environ["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], + azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], + credential=AzureCliCredential(), + ), instructions="You are a helpful weather agent.", tools=[get_weather], ) diff --git a/python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_with_function_tools.py b/python/samples/02-agents/providers/azure/openai_chat_completion_client_with_function_tools.py similarity index 100% rename from python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_with_function_tools.py rename to python/samples/02-agents/providers/azure/openai_chat_completion_client_with_function_tools.py diff --git a/python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_with_session.py b/python/samples/02-agents/providers/azure/openai_chat_completion_client_with_session.py similarity index 100% rename from python/samples/02-agents/providers/azure/openai_chat_completion_client_azure_with_session.py rename to python/samples/02-agents/providers/azure/openai_chat_completion_client_with_session.py diff --git a/python/samples/02-agents/providers/azure/openai_client_basic.py b/python/samples/02-agents/providers/azure/openai_client_basic.py new file mode 100644 index 0000000000..3029c03cda --- /dev/null +++ b/python/samples/02-agents/providers/azure/openai_client_basic.py @@ -0,0 +1,90 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os +from random import randint +from typing import Annotated + +from agent_framework import Agent, tool +from agent_framework.openai import OpenAIChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv +from pydantic import Field + +# Load environment variables from .env file +load_dotenv() + +""" +Azure OpenAI Chat Client Basic Example + +This sample demonstrates basic usage of OpenAIChatClient with explicit Azure +settings and a credential, showing both streaming and non-streaming responses. +""" + + +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production. +@tool(approval_mode="never_require") +def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +async def non_streaming_example() -> None: + """Example of non-streaming response (get the complete result at once).""" + print("=== Non-streaming Response Example ===") + + agent = Agent( + client=OpenAIChatClient( + model=os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME"), + azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), + api_version=os.getenv("AZURE_OPENAI_API_VERSION"), + credential=AzureCliCredential(), + ), + name="WeatherAgent", + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + query = "What's the weather in Seattle?" + print(f"User: {query}") + result = await agent.run(query) + print(f"Result: {result}\n") + + +async def streaming_example() -> None: + """Example of streaming response (get results as they are generated).""" + print("=== Streaming Response Example ===") + + agent = Agent( + client=OpenAIChatClient( + model=os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME"), + azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), + api_version=os.getenv("AZURE_OPENAI_API_VERSION"), + credential=AzureCliCredential(), + ), + name="WeatherAgent", + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + query = "What's the weather in Portland?" + print(f"User: {query}") + print("Agent: ", end="", flush=True) + async for chunk in agent.run(query, stream=True): + if chunk.text: + print(chunk.text, end="", flush=True) + print("\n") + + +async def main() -> None: + print("=== Basic Azure OpenAI Chat Client Agent Example ===") + + await non_streaming_example() + await streaming_example() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure/openai_client_with_function_tools.py b/python/samples/02-agents/providers/azure/openai_client_with_function_tools.py new file mode 100644 index 0000000000..8080b65418 --- /dev/null +++ b/python/samples/02-agents/providers/azure/openai_client_with_function_tools.py @@ -0,0 +1,137 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from datetime import datetime, timezone +from random import randint +from typing import Annotated + +from agent_framework import Agent, tool +from agent_framework.openai import OpenAIChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv +from pydantic import Field + +# Load environment variables from .env file +load_dotenv() + +""" +Azure OpenAI Chat Client with Function Tools Example + +This sample demonstrates function tool integration with Azure OpenAI Chat Client, +showing both agent-level and query-level tool configuration patterns. +""" + + +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +@tool(approval_mode="never_require") +def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +@tool(approval_mode="never_require") +def get_time() -> str: + """Get the current UTC time.""" + current_time = datetime.now(timezone.utc) + return f"The current UTC time is {current_time.strftime('%Y-%m-%d %H:%M:%S')}." + + +async def tools_on_agent_level() -> None: + """Example showing tools defined when creating the agent.""" + print("=== Tools Defined on Agent Level ===") + + # Tools are provided when creating the agent + # The agent can use these tools for any query during its lifetime + agent = Agent( + client=OpenAIChatClient(credential=AzureCliCredential()), + instructions="You are a helpful assistant that can provide weather and time information.", + tools=[get_weather, get_time], # Tools defined at agent creation + ) + + # First query - agent can use weather tool + query1 = "What's the weather like in New York?" + print(f"User: {query1}") + result1 = await agent.run(query1) + print(f"Agent: {result1}\n") + + # Second query - agent can use time tool + query2 = "What's the current UTC time?" + print(f"User: {query2}") + result2 = await agent.run(query2) + print(f"Agent: {result2}\n") + + # Third query - agent can use both tools if needed + query3 = "What's the weather in London and what's the current UTC time?" + print(f"User: {query3}") + result3 = await agent.run(query3) + print(f"Agent: {result3}\n") + + +async def tools_on_run_level() -> None: + """Example showing tools passed to the run method.""" + print("=== Tools Passed to Run Method ===") + + # Agent created without tools + agent = Agent( + client=OpenAIChatClient(credential=AzureCliCredential()), + instructions="You are a helpful assistant.", + # No tools defined here + ) + + # First query with weather tool + query1 = "What's the weather like in Seattle?" + print(f"User: {query1}") + result1 = await agent.run(query1, tools=[get_weather]) # Tool passed to run method + print(f"Agent: {result1}\n") + + # Second query with time tool + query2 = "What's the current UTC time?" + print(f"User: {query2}") + result2 = await agent.run(query2, tools=[get_time]) # Different tool for this query + print(f"Agent: {result2}\n") + + # Third query with multiple tools + query3 = "What's the weather in Chicago and what's the current UTC time?" + print(f"User: {query3}") + result3 = await agent.run(query3, tools=[get_weather, get_time]) # Multiple tools + print(f"Agent: {result3}\n") + + +async def mixed_tools_example() -> None: + """Example showing both agent-level tools and run-method tools.""" + print("=== Mixed Tools Example (Agent + Run Method) ===") + + # Agent created with some base tools + agent = Agent( + client=OpenAIChatClient(credential=AzureCliCredential()), + instructions="You are a comprehensive assistant that can help with various information requests.", + tools=[get_weather], # Base tool available for all queries + ) + + # Query using both agent tool and additional run-method tools + query = "What's the weather in Denver and what's the current UTC time?" + print(f"User: {query}") + + # Agent has access to get_weather (from creation) + additional tools from run method + result = await agent.run( + query, + tools=[get_time], # Additional tools for this specific query + ) + print(f"Agent: {result}\n") + + +async def main() -> None: + print("=== Azure OpenAI Chat Client Agent with Function Tools Examples ===\n") + + await tools_on_agent_level() + await tools_on_run_level() + await mixed_tools_example() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure/openai_client_with_session.py b/python/samples/02-agents/providers/azure/openai_client_with_session.py new file mode 100644 index 0000000000..ad1c87f2d2 --- /dev/null +++ b/python/samples/02-agents/providers/azure/openai_client_with_session.py @@ -0,0 +1,152 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from random import randint +from typing import Annotated + +from agent_framework import Agent, AgentSession, tool +from agent_framework.openai import OpenAIChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv +from pydantic import Field + +# Load environment variables from .env file +load_dotenv() + +""" +Azure OpenAI Chat Client with Session Management Example + +This sample demonstrates session management with Azure OpenAI Chat Client, showing +persistent conversation context and simplified response handling. +""" + + +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +@tool(approval_mode="never_require") +def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +async def example_with_automatic_session_creation() -> None: + """Example showing automatic session creation.""" + print("=== Automatic Session Creation Example ===") + + agent = Agent( + client=OpenAIChatClient(credential=AzureCliCredential()), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # First conversation - no session provided, will be created automatically + query1 = "What's the weather like in Seattle?" + print(f"User: {query1}") + result1 = await agent.run(query1) + print(f"Agent: {result1.text}") + + # Second conversation - still no session provided, will create another new session + query2 = "What was the last city I asked about?" + print(f"\nUser: {query2}") + result2 = await agent.run(query2) + print(f"Agent: {result2.text}") + print("Note: Each call creates a separate session, so the agent doesn't remember previous context.\n") + + +async def example_with_session_persistence_in_memory() -> None: + """ + Example showing session persistence across multiple conversations. + In this example, messages are stored in-memory. + """ + print("=== Session Persistence Example (In-Memory) ===") + + agent = Agent( + client=OpenAIChatClient(credential=AzureCliCredential()), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # Create a new session that will be reused + session = agent.create_session() + + # First conversation + query1 = "What's the weather like in Tokyo?" + print(f"User: {query1}") + result1 = await agent.run(query1, session=session, store=False) + print(f"Agent: {result1.text}") + + # Second conversation using the same session - maintains context + query2 = "How about London?" + print(f"\nUser: {query2}") + result2 = await agent.run(query2, session=session, store=False) + print(f"Agent: {result2.text}") + + # Third conversation - agent should remember both previous cities + query3 = "Which of the cities I asked about has better weather?" + print(f"\nUser: {query3}") + result3 = await agent.run(query3, session=session, store=False) + print(f"Agent: {result3.text}") + print("Note: The agent remembers context from previous messages in the same session.\n") + + +async def example_with_existing_session_id() -> None: + """ + Example showing how to work with an existing session ID from the service. + In this example, messages are stored on the server using OpenAI conversation state. + """ + print("=== Existing Session ID Example ===") + + # First, create a conversation and capture the session ID + existing_session_id = None + + agent = Agent( + client=OpenAIChatClient(credential=AzureCliCredential()), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # Start a conversation and get the session ID + session = agent.create_session() + + query1 = "What's the weather in Paris?" + print(f"User: {query1}") + result1 = await agent.run(query1, session=session) + print(f"Agent: {result1.text}") + + # The session ID is set after the first response + existing_session_id = session.service_session_id + print(f"Session ID: {existing_session_id}") + + if existing_session_id: + print("\n--- Continuing with the same session ID in a new agent instance ---") + + agent = Agent( + client=OpenAIChatClient(credential=AzureCliCredential()), + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + # Create a session with the existing ID + session = AgentSession(service_session_id=existing_session_id) + + query2 = "What was the last city I asked about?" + print(f"User: {query2}") + result2 = await agent.run(query2, session=session) + print(f"Agent: {result2.text}") + print("Note: The agent continues the conversation from the previous session by using session ID.\n") + + +async def main() -> None: + print("=== Azure OpenAI Chat Client Session Management Examples ===\n") + + await example_with_automatic_session_creation() + await example_with_session_persistence_in_memory() + await example_with_existing_session_id() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/providers/azure/openai_client_with_structured_output.py b/python/samples/02-agents/providers/azure/openai_client_with_structured_output.py new file mode 100644 index 0000000000..3c09efd6d4 --- /dev/null +++ b/python/samples/02-agents/providers/azure/openai_client_with_structured_output.py @@ -0,0 +1,93 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from agent_framework import Agent, AgentResponse +from agent_framework.openai import OpenAIChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv +from pydantic import BaseModel + +# Load environment variables from .env file +load_dotenv() + +""" +Azure OpenAI Chat Client with Structured Output Example + +This sample demonstrates using structured output capabilities with Azure OpenAI Chat Client, +showing Pydantic model integration for type-safe response parsing and data extraction. +""" + + +class OutputStruct(BaseModel): + """A structured output for testing purposes.""" + + city: str + description: str + + +async def non_streaming_example() -> None: + print("=== Non-streaming example ===") + + # Create an Azure OpenAI Chat agent + agent = Agent( + client=OpenAIChatClient(credential=AzureCliCredential()), + name="CityAgent", + instructions="You are a helpful agent that describes cities in a structured format.", + ) + + # Ask the agent about a city + query = "Tell me about Paris, France" + print(f"User: {query}") + + # Get structured response from the agent using response_format parameter + result = await agent.run(query, options={"response_format": OutputStruct}) + + # Access the structured output using the parsed value + if structured_data := result.value: + print("Structured Output Agent:") + print(f"City: {structured_data.city}") + print(f"Description: {structured_data.description}") + else: + print(f"Failed to parse response: {result.text}") + + +async def streaming_example() -> None: + print("=== Streaming example ===") + + # Create an Azure OpenAI Chat agent + agent = Agent( + client=OpenAIChatClient(credential=AzureCliCredential()), + name="CityAgent", + instructions="You are a helpful agent that describes cities in a structured format.", + ) + + # Ask the agent about a city + query = "Tell me about Tokyo, Japan" + print(f"User: {query}") + + # Get structured response from streaming agent using AgentResponse.from_update_generator + # This method collects all streaming updates and combines them into a single AgentResponse + result = await AgentResponse.from_update_generator( + agent.run(query, stream=True, options={"response_format": OutputStruct}), + output_format_type=OutputStruct, + ) + + # Access the structured output using the parsed value + if structured_data := result.value: + print("Structured Output (from streaming with AgentResponse.from_update_generator):") + print(f"City: {structured_data.city}") + print(f"Description: {structured_data.description}") + else: + print(f"Failed to parse response: {result.text}") + + +async def main() -> None: + print("=== Azure OpenAI Chat Client Agent with Structured Output ===") + + await non_streaming_example() + await streaming_example() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/providers/custom/README.md b/python/samples/02-agents/providers/custom/README.md index ac58a77e69..766e5e0269 100644 --- a/python/samples/02-agents/providers/custom/README.md +++ b/python/samples/02-agents/providers/custom/README.md @@ -27,7 +27,7 @@ Both approaches allow you to extend the framework for your specific use cases wh ## Understanding Raw Client Classes -The framework provides `Raw...Client` classes (e.g., `RawOpenAIChatClient`, `RawOpenAIResponsesClient`, `RawAzureAIClient`) that are intermediate implementations without middleware, telemetry, or function invocation support. +The framework provides `Raw...Client` classes (e.g., `RawOpenAIChatClient`, `RawOpenAIChatCompletionClient`, `RawAzureAIClient`) that are intermediate implementations without middleware, telemetry, or function invocation support. ### Warning: Raw Clients Should Not Normally Be Used Directly @@ -60,8 +60,8 @@ class MyCustomClient( For most use cases, use the fully-featured public client classes which already have all layers correctly composed: -- `OpenAIChatClient` - OpenAI Chat completions with all layers -- `OpenAIResponsesClient` - OpenAI Responses API with all layers +- `OpenAIChatCompletionClient` - OpenAI Chat Completions API with all layers +- `OpenAIChatClient` - OpenAI Responses API with all layers - `AzureOpenAIChatClient` - Azure OpenAI Chat with all layers - `AzureOpenAIResponsesClient` - Azure OpenAI Responses with all layers - `AzureAIClient` - Azure AI Project with all layers diff --git a/python/samples/02-agents/providers/openai/README.md b/python/samples/02-agents/providers/openai/README.md index 20e757d421..db71abfa89 100644 --- a/python/samples/02-agents/providers/openai/README.md +++ b/python/samples/02-agents/providers/openai/README.md @@ -1,67 +1,63 @@ -# OpenAI Agent Framework Examples +# OpenAI Provider Samples -This folder contains examples demonstrating different ways to create and use agents with the OpenAI clients from the `agent_framework.openai` package. +This folder contains OpenAI provider samples for the generic clients in +`agent_framework.openai`. -## Examples +## Chat Completions API samples (`OpenAIChatCompletionClient`) | File | Description | |------|-------------| -| [`openai_assistants_basic.py`](openai_assistants_basic.py) | Basic usage of `OpenAIAssistantProvider` with streaming and non-streaming responses. | -| [`openai_assistants_provider_methods.py`](openai_assistants_provider_methods.py) | Demonstrates all `OpenAIAssistantProvider` methods: `create_agent()`, `get_agent()`, and `as_agent()`. | -| [`openai_assistants_with_code_interpreter.py`](openai_assistants_with_code_interpreter.py) | Using `OpenAIAssistantsClient.get_code_interpreter_tool()` with `OpenAIAssistantProvider` to execute Python code. | -| [`openai_assistants_with_existing_assistant.py`](openai_assistants_with_existing_assistant.py) | Working with pre-existing assistants using `get_agent()` and `as_agent()` methods. | -| [`openai_assistants_with_explicit_settings.py`](openai_assistants_with_explicit_settings.py) | Configuring `OpenAIAssistantProvider` with explicit settings including API key and model ID. | -| [`openai_assistants_with_file_search.py`](openai_assistants_with_file_search.py) | Using `OpenAIAssistantsClient.get_file_search_tool()` with `OpenAIAssistantProvider` for file search capabilities. | -| [`openai_assistants_with_function_tools.py`](openai_assistants_with_function_tools.py) | Function tools with `OpenAIAssistantProvider` at both agent-level and query-level. | -| [`openai_assistants_with_response_format.py`](openai_assistants_with_response_format.py) | Structured outputs with `OpenAIAssistantProvider` using Pydantic models. | -| [`openai_assistants_with_session.py`](openai_assistants_with_session.py) | Session management with `OpenAIAssistantProvider` for conversation context persistence. | -| [`openai_chat_client_basic.py`](openai_chat_client_basic.py) | The simplest way to create an agent using `Agent` with `OpenAIChatClient`. Shows both streaming and non-streaming responses for chat-based interactions with OpenAI models. | -| [`openai_chat_client_with_explicit_settings.py`](openai_chat_client_with_explicit_settings.py) | Shows how to initialize an agent with a specific chat client, configuring settings explicitly including API key and model ID. | -| [`openai_chat_client_with_function_tools.py`](openai_chat_client_with_function_tools.py) | Demonstrates how to use function tools with agents. Shows both agent-level tools (defined when creating the agent) and query-level tools (provided with specific queries). | -| [`openai_chat_client_with_local_mcp.py`](openai_chat_client_with_local_mcp.py) | Shows how to integrate OpenAI agents with local Model Context Protocol (MCP) servers for enhanced functionality and tool integration. | -| [`openai_chat_client_with_session.py`](openai_chat_client_with_session.py) | Demonstrates session management with OpenAI agents, including automatic session creation for stateless conversations and explicit session management for maintaining conversation context across multiple interactions. | -| [`openai_chat_client_with_web_search.py`](openai_chat_client_with_web_search.py) | Shows how to use `OpenAIChatClient.get_web_search_tool()` for web search capabilities with OpenAI agents. | -| [`openai_chat_client_with_runtime_json_schema.py`](openai_chat_client_with_runtime_json_schema.py) | Shows how to supply a runtime JSON Schema via `additional_chat_options` for structured output without defining a Pydantic model. | -| [`openai_responses_client_basic.py`](openai_responses_client_basic.py) | The simplest way to create an agent using `Agent` with `OpenAIResponsesClient`. Shows both streaming and non-streaming responses for structured response generation with OpenAI models. | -| [`openai_responses_client_image_analysis.py`](openai_responses_client_image_analysis.py) | Demonstrates how to use vision capabilities with agents to analyze images. | -| [`openai_responses_client_image_generation.py`](openai_responses_client_image_generation.py) | Demonstrates how to use `OpenAIResponsesClient.get_image_generation_tool()` to create images based on text descriptions. | -| [`openai_responses_client_reasoning.py`](openai_responses_client_reasoning.py) | Demonstrates how to use reasoning capabilities with OpenAI agents, showing how the agent can provide detailed reasoning for its responses. | -| [`openai_responses_client_streaming_image_generation.py`](openai_responses_client_streaming_image_generation.py) | Demonstrates streaming image generation with partial images for real-time image creation feedback and improved user experience. | -| [`openai_responses_client_with_agent_as_tool.py`](openai_responses_client_with_agent_as_tool.py) | Shows how to use the agent-as-tool pattern with OpenAI Responses Client, where one agent delegates work to specialized sub-agents wrapped as tools using `as_tool()`. Demonstrates hierarchical agent architectures. | -| [`openai_responses_client_with_code_interpreter.py`](openai_responses_client_with_code_interpreter.py) | Shows how to use `OpenAIResponsesClient.get_code_interpreter_tool()` to write and execute Python code. | -| [`openai_responses_client_with_code_interpreter_files.py`](openai_responses_client_with_code_interpreter_files.py) | Shows how to use code interpreter with uploaded files for data analysis. | -| [`openai_responses_client_with_explicit_settings.py`](openai_responses_client_with_explicit_settings.py) | Shows how to initialize an agent with a specific responses client, configuring settings explicitly including API key and model ID. | -| [`openai_responses_client_with_file_search.py`](openai_responses_client_with_file_search.py) | Demonstrates how to use `OpenAIResponsesClient.get_file_search_tool()` for searching through uploaded files. | -| [`openai_responses_client_with_function_tools.py`](openai_responses_client_with_function_tools.py) | Demonstrates how to use function tools with agents. Shows both agent-level tools (defined when creating the agent) and run-level tools (provided with specific queries). | -| [`openai_responses_client_with_hosted_mcp.py`](openai_responses_client_with_hosted_mcp.py) | Shows how to use `OpenAIResponsesClient.get_mcp_tool()` for hosted MCP servers, including approval workflows. | -| [`openai_responses_client_with_local_mcp.py`](openai_responses_client_with_local_mcp.py) | Shows how to integrate OpenAI agents with local Model Context Protocol (MCP) servers for enhanced functionality and tool integration. | -| [`openai_responses_client_with_runtime_json_schema.py`](openai_responses_client_with_runtime_json_schema.py) | Shows how to supply a runtime JSON Schema via `additional_chat_options` for structured output without defining a Pydantic model. | -| [`openai_responses_client_with_structured_output.py`](openai_responses_client_with_structured_output.py) | Demonstrates how to use structured outputs with OpenAI agents to get structured data responses in predefined formats. | -| [`openai_responses_client_with_session.py`](openai_responses_client_with_session.py) | Demonstrates session management with OpenAI agents, including automatic session creation for stateless conversations and explicit session management for maintaining conversation context across multiple interactions. | -| [`openai_responses_client_with_web_search.py`](openai_responses_client_with_web_search.py) | Shows how to use `OpenAIResponsesClient.get_web_search_tool()` for web search capabilities. | +| [`chat_completion_client_basic.py`](chat_completion_client_basic.py) | Basic non-streaming and streaming chat completion sample with an explicit `gpt-5.4-nano` model and API key. | +| [`chat_completion_client_with_explicit_settings.py`](chat_completion_client_with_explicit_settings.py) | Chat completion sample with explicit model and API key settings. | +| [`chat_completion_client_with_function_tools.py`](chat_completion_client_with_function_tools.py) | Function tools with agent-level and run-level patterns. | +| [`chat_completion_client_with_local_mcp.py`](chat_completion_client_with_local_mcp.py) | Local MCP integration with the chat completions client. | +| [`chat_completion_client_with_runtime_json_schema.py`](chat_completion_client_with_runtime_json_schema.py) | Runtime JSON schema output with the chat completions client. | +| [`chat_completion_client_with_session.py`](chat_completion_client_with_session.py) | Session management with the chat completions client. | +| [`chat_completion_client_with_web_search.py`](chat_completion_client_with_web_search.py) | Web search with the chat completions client. | + +## Responses API samples (`OpenAIChatClient`) + +| File | Description | +|------|-------------| +| [`client_basic.py`](client_basic.py) | Basic non-streaming and streaming responses sample with an explicit `gpt-5.4-nano` model and API key. | +| [`client_image_analysis.py`](client_image_analysis.py) | Analyze images with the responses client. | +| [`client_image_generation.py`](client_image_generation.py) | Generate images from text prompts. | +| [`client_reasoning.py`](client_reasoning.py) | Reasoning-focused sample for models such as `gpt-5`. | +| [`client_streaming_image_generation.py`](client_streaming_image_generation.py) | Streaming image generation sample. | +| [`client_with_agent_as_tool.py`](client_with_agent_as_tool.py) | Agent-as-tool orchestration pattern. | +| [`client_with_code_interpreter.py`](client_with_code_interpreter.py) | Code interpreter sample. | +| [`client_with_code_interpreter_files.py`](client_with_code_interpreter_files.py) | Code interpreter sample with uploaded files. | +| [`client_with_explicit_settings.py`](client_with_explicit_settings.py) | Responses client with explicit model and API key settings. | +| [`client_with_file_search.py`](client_with_file_search.py) | Hosted file search sample. | +| [`client_with_function_tools.py`](client_with_function_tools.py) | Function tools with agent-level and run-level patterns. | +| [`client_with_hosted_mcp.py`](client_with_hosted_mcp.py) | Hosted MCP tools and approval workflows. | +| [`client_with_local_mcp.py`](client_with_local_mcp.py) | Local MCP integration with the responses client. | +| [`client_with_local_shell.py`](client_with_local_shell.py) | Local shell tool sample. | +| [`client_with_runtime_json_schema.py`](client_with_runtime_json_schema.py) | Runtime JSON schema output with the responses client. | +| [`client_with_session.py`](client_with_session.py) | Session management with the responses client. | +| [`client_with_shell.py`](client_with_shell.py) | Hosted shell tool sample. | +| [`client_with_structured_output.py`](client_with_structured_output.py) | Structured output with Pydantic models. | +| [`client_with_web_search.py`](client_with_web_search.py) | Web search with the responses client. | ## Environment Variables -Make sure to set the following environment variables before running the examples: +Set these before running the OpenAI provider samples: -- `OPENAI_API_KEY`: Your OpenAI API key -- `OPENAI_CHAT_MODEL_ID`: The OpenAI model to use (e.g., `gpt-4o`, `gpt-4o-mini`, `gpt-3.5-turbo`) -- `OPENAI_RESPONSES_MODEL_ID`: The OpenAI model to use (e.g., `gpt-4o`, `gpt-4o-mini`, `gpt-3.5-turbo`) -- For image processing examples, use a vision-capable model like `gpt-4o` or `gpt-4o-mini` +- `OPENAI_API_KEY` +- `OPENAI_MODEL` -Optionally, you can set: -- `OPENAI_ORG_ID`: Your OpenAI organization ID (if applicable) -- `OPENAI_API_BASE_URL`: Your OpenAI base URL (if using a different base URL) +Optionally, you can also set: + +- `OPENAI_ORG_ID` +- `OPENAI_BASE_URL` + +If your shell also contains `AZURE_OPENAI_*` variables, these samples still stay on OpenAI as long as +`OPENAI_API_KEY` is present. To force Azure routing with the generic clients, pass an explicit Azure +input such as `credential`, `azure_endpoint`, or `api_version`, or use the Azure provider samples. ## Optional Dependencies -Some examples require additional dependencies: +Some samples need extra packages: -- **Image Generation Example**: The `openai_responses_client_image_generation.py` example requires PIL (Pillow) for image display. Install with: - ```bash - # Using uv - uv add pillow - - # Or using pip - pip install pillow - ``` +- `client_image_generation.py` and `client_streaming_image_generation.py` use Pillow for image display. +- MCP samples require the relevant MCP server/tooling you configure locally. diff --git a/python/samples/02-agents/providers/openai/chat_completion_client_basic.py b/python/samples/02-agents/providers/openai/chat_completion_client_basic.py new file mode 100644 index 0000000000..9573ef0b16 --- /dev/null +++ b/python/samples/02-agents/providers/openai/chat_completion_client_basic.py @@ -0,0 +1,85 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os +from random import randint +from typing import Annotated + +from agent_framework import Agent, tool +from agent_framework.openai import OpenAIChatCompletionClient +from dotenv import load_dotenv +from pydantic import Field + +# Load environment variables from .env file +load_dotenv() + +""" +OpenAI Chat Completion Client Basic Example + +This sample demonstrates basic usage of OpenAIChatCompletionClient with explicit model and +API key settings, showing both streaming and non-streaming responses. +""" + + +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production. +@tool(approval_mode="never_require") +def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +async def non_streaming_example() -> None: + """Example of non-streaming response (get the complete result at once).""" + print("=== Non-streaming Response Example ===") + + agent = Agent( + client=OpenAIChatCompletionClient( + model="gpt-5.4-nano", + api_key=os.getenv("OPENAI_API_KEY"), + ), + name="WeatherAgent", + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + query = "What's the weather like in Seattle?" + print(f"User: {query}") + result = await agent.run(query) + print(f"Result: {result}\n") + + +async def streaming_example() -> None: + """Example of streaming response (get results as they are generated).""" + print("=== Streaming Response Example ===") + + agent = Agent( + client=OpenAIChatCompletionClient( + model="gpt-5.4-nano", + api_key=os.getenv("OPENAI_API_KEY"), + ), + name="WeatherAgent", + instructions="You are a helpful weather agent.", + tools=get_weather, + ) + + query = "What's the weather like in Portland?" + print(f"User: {query}") + print("Agent: ", end="", flush=True) + async for chunk in agent.run(query, stream=True): + if chunk.text: + print(chunk.text, end="", flush=True) + print("\n") + + +async def main() -> None: + print("=== Basic OpenAI Chat Completion Client Agent Example ===") + + await non_streaming_example() + await streaming_example() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_explicit_settings.py b/python/samples/02-agents/providers/openai/chat_completion_client_with_explicit_settings.py similarity index 74% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_explicit_settings.py rename to python/samples/02-agents/providers/openai/chat_completion_client_with_explicit_settings.py index 20a3f720d1..59d11a4fda 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_explicit_settings.py +++ b/python/samples/02-agents/providers/openai/chat_completion_client_with_explicit_settings.py @@ -6,7 +6,7 @@ from random import randint from typing import Annotated from agent_framework import Agent, tool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatCompletionClient from dotenv import load_dotenv from pydantic import Field @@ -14,9 +14,9 @@ from pydantic import Field load_dotenv() """ -OpenAI Responses Client with Explicit Settings Example +OpenAI Chat Completion Client with Explicit Settings Example -This sample demonstrates creating OpenAI Responses Client with explicit configuration +This sample demonstrates creating OpenAI Chat Completion Client with explicit configuration settings rather than relying on environment variable defaults. """ @@ -34,15 +34,13 @@ def get_weather( async def main() -> None: - print("=== OpenAI Responses Client with Explicit Settings ===") - - _client = OpenAIResponsesClient( - model=os.environ["OPENAI_MODEL"], - api_key=os.environ["OPENAI_API_KEY"], - ) + print("=== OpenAI Chat Completion Client with Explicit Settings ===") agent = Agent( - client=_client, + client=OpenAIChatCompletionClient( + model=os.environ["OPENAI_MODEL"], + api_key=os.environ["OPENAI_API_KEY"], + ), instructions="You are a helpful weather agent.", tools=get_weather, ) diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_function_tools.py b/python/samples/02-agents/providers/openai/chat_completion_client_with_function_tools.py similarity index 91% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_function_tools.py rename to python/samples/02-agents/providers/openai/chat_completion_client_with_function_tools.py index 55f0ed9e19..7bbc6b9744 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_function_tools.py +++ b/python/samples/02-agents/providers/openai/chat_completion_client_with_function_tools.py @@ -6,7 +6,7 @@ from random import randint from typing import Annotated from agent_framework import Agent, tool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatCompletionClient from dotenv import load_dotenv from pydantic import Field @@ -14,9 +14,9 @@ from pydantic import Field load_dotenv() """ -OpenAI Responses Client with Function Tools Example +OpenAI Chat Completion Client with Function Tools Example -This sample demonstrates function tool integration with OpenAI Responses Client, +This sample demonstrates function tool integration with OpenAI Chat Completion Client, showing both agent-level and query-level tool configuration patterns. """ @@ -47,7 +47,7 @@ async def tools_on_agent_level() -> None: # Tools are provided when creating the agent # The agent can use these tools for any query during its lifetime agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatCompletionClient(), instructions="You are a helpful assistant that can provide weather and time information.", tools=[get_weather, get_time], # Tools defined at agent creation ) @@ -77,7 +77,7 @@ async def tools_on_run_level() -> None: # Agent created without tools agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatCompletionClient(), instructions="You are a helpful assistant.", # No tools defined here ) @@ -107,7 +107,7 @@ async def mixed_tools_example() -> None: # Agent created with some base tools agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatCompletionClient(), instructions="You are a comprehensive assistant that can help with various information requests.", tools=[get_weather], # Base tool available for all queries ) @@ -125,7 +125,7 @@ async def mixed_tools_example() -> None: async def main() -> None: - print("=== OpenAI Responses Client Agent with Function Tools Examples ===\n") + print("=== OpenAI Chat Completion Client Agent with Function Tools Examples ===\n") await tools_on_agent_level() await tools_on_run_level() diff --git a/python/samples/02-agents/providers/openai/openai_chat_client_with_local_mcp.py b/python/samples/02-agents/providers/openai/chat_completion_client_with_local_mcp.py similarity index 88% rename from python/samples/02-agents/providers/openai/openai_chat_client_with_local_mcp.py rename to python/samples/02-agents/providers/openai/chat_completion_client_with_local_mcp.py index 00057dd76d..1c2387dc24 100644 --- a/python/samples/02-agents/providers/openai/openai_chat_client_with_local_mcp.py +++ b/python/samples/02-agents/providers/openai/chat_completion_client_with_local_mcp.py @@ -3,17 +3,17 @@ import asyncio from agent_framework import Agent, MCPStreamableHTTPTool -from agent_framework.openai import OpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Chat Client with Local MCP Example +OpenAI Chat Completion Client with Local MCP Example This sample demonstrates integrating Model Context Protocol (MCP) tools with -OpenAI Chat Client for extended functionality and external service access. +OpenAI Chat Completion Client for extended functionality and external service access. The Agent Framework now supports enhanced metadata extraction from MCP tool results, including error states, token usage, costs, and other arbitrary @@ -34,7 +34,7 @@ async def mcp_tools_on_run_level() -> None: url="https://learn.microsoft.com/api/mcp", ) as mcp_server, Agent( - client=OpenAIChatClient(), + client=OpenAIChatCompletionClient(), name="DocsAgent", instructions="You are a helpful assistant that can help with microsoft documentation questions.", ) as agent, @@ -60,7 +60,7 @@ async def mcp_tools_on_agent_level() -> None: # The agent can use these tools for any query during its lifetime # The agent will connect to the MCP server through its context manager. async with Agent( - client=OpenAIChatClient(), + client=OpenAIChatCompletionClient(), name="DocsAgent", instructions="You are a helpful assistant that can help with microsoft documentation questions.", tools=MCPStreamableHTTPTool( # Tools defined at agent creation @@ -82,7 +82,7 @@ async def mcp_tools_on_agent_level() -> None: async def main() -> None: - print("=== OpenAI Chat Client Agent with MCP Tools Examples ===\n") + print("=== OpenAI Chat Completion Client Agent with MCP Tools Examples ===\n") await mcp_tools_on_agent_level() await mcp_tools_on_run_level() diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_runtime_json_schema.py b/python/samples/02-agents/providers/openai/chat_completion_client_with_runtime_json_schema.py similarity index 89% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_runtime_json_schema.py rename to python/samples/02-agents/providers/openai/chat_completion_client_with_runtime_json_schema.py index cdc4ce13fb..4cf5dfb844 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_runtime_json_schema.py +++ b/python/samples/02-agents/providers/openai/chat_completion_client_with_runtime_json_schema.py @@ -4,14 +4,14 @@ import asyncio import json from agent_framework import Agent -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatCompletionClient, OpenAIChatOptions from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Chat Client Runtime JSON Schema Example +OpenAI Chat Completion Client Runtime JSON Schema Example Demonstrates structured outputs when the schema is only known at runtime. Uses additional_chat_options to pass a JSON Schema payload directly to OpenAI @@ -38,7 +38,7 @@ async def non_streaming_example() -> None: print("=== Non-streaming runtime JSON schema example ===") agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatCompletionClient[OpenAIChatOptions](), name="RuntimeSchemaAgent", instructions="Return only JSON that matches the provided schema. Do not add commentary.", ) @@ -72,7 +72,7 @@ async def streaming_example() -> None: print("=== Streaming runtime JSON schema example ===") agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatCompletionClient(), name="RuntimeSchemaAgent", instructions="Return only JSON that matches the provided schema. Do not add commentary.", ) @@ -108,7 +108,7 @@ async def streaming_example() -> None: async def main() -> None: - print("=== OpenAI Chat Client with runtime JSON Schema ===") + print("=== OpenAI Chat Completion Client with runtime JSON Schema ===") await non_streaming_example() await streaming_example() diff --git a/python/samples/02-agents/providers/openai/openai_chat_client_with_session.py b/python/samples/02-agents/providers/openai/chat_completion_client_with_session.py similarity index 91% rename from python/samples/02-agents/providers/openai/openai_chat_client_with_session.py rename to python/samples/02-agents/providers/openai/chat_completion_client_with_session.py index 773b18b3cc..99aac09e36 100644 --- a/python/samples/02-agents/providers/openai/openai_chat_client_with_session.py +++ b/python/samples/02-agents/providers/openai/chat_completion_client_with_session.py @@ -5,7 +5,7 @@ from random import randint from typing import Annotated from agent_framework import Agent, AgentSession, InMemoryHistoryProvider, tool -from agent_framework.openai import OpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from dotenv import load_dotenv from pydantic import Field @@ -13,9 +13,9 @@ from pydantic import Field load_dotenv() """ -OpenAI Chat Client with Session Management Example +OpenAI Chat Completion Client with Session Management Example -This sample demonstrates session management with OpenAI Chat Client, showing +This sample demonstrates session management with OpenAI Chat Completion Client, showing conversation sessions and message history preservation across interactions. """ @@ -37,7 +37,7 @@ async def example_with_automatic_session_creation() -> None: print("=== Automatic Session Creation Example ===") agent = Agent( - client=OpenAIChatClient(), + client=OpenAIChatCompletionClient(), instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -62,7 +62,7 @@ async def example_with_session_persistence() -> None: print("Using the same session across multiple conversations to maintain context.\n") agent = Agent( - client=OpenAIChatClient(), + client=OpenAIChatCompletionClient(), instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -95,7 +95,7 @@ async def example_with_existing_session_messages() -> None: print("=== Existing Session Messages Example ===") agent = Agent( - client=OpenAIChatClient(), + client=OpenAIChatCompletionClient(), instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -118,7 +118,7 @@ async def example_with_existing_session_messages() -> None: # Create a new agent instance but use the existing session with its message history new_agent = Agent( - client=OpenAIChatClient(), + client=OpenAIChatCompletionClient(), instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -142,7 +142,7 @@ async def example_with_existing_session_messages() -> None: async def main() -> None: - print("=== OpenAI Chat Client Agent Session Management Examples ===\n") + print("=== OpenAI Chat Completion Client Agent Session Management Examples ===\n") await example_with_automatic_session_creation() await example_with_session_persistence() diff --git a/python/samples/02-agents/providers/openai/openai_chat_client_with_web_search.py b/python/samples/02-agents/providers/openai/chat_completion_client_with_web_search.py similarity index 86% rename from python/samples/02-agents/providers/openai/openai_chat_client_with_web_search.py rename to python/samples/02-agents/providers/openai/chat_completion_client_with_web_search.py index 623dc25f43..f5da9ba633 100644 --- a/python/samples/02-agents/providers/openai/openai_chat_client_with_web_search.py +++ b/python/samples/02-agents/providers/openai/chat_completion_client_with_web_search.py @@ -3,22 +3,22 @@ import asyncio from agent_framework import Agent -from agent_framework.openai import OpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Chat Client with Web Search Example +OpenAI Chat Completion Client with Web Search Example -This sample demonstrates using get_web_search_tool() with OpenAI Chat Client +This sample demonstrates using get_web_search_tool() with OpenAI Chat Completion Client for real-time information retrieval and current data access. """ async def main() -> None: - client = OpenAIChatClient(model="gpt-4o-search-preview") + client = OpenAIChatCompletionClient(model="gpt-4o-search-preview") # Create web search tool with location context web_search_tool = client.get_web_search_tool( diff --git a/python/samples/02-agents/providers/openai/openai_chat_client_basic.py b/python/samples/02-agents/providers/openai/client_basic.py similarity index 73% rename from python/samples/02-agents/providers/openai/openai_chat_client_basic.py rename to python/samples/02-agents/providers/openai/client_basic.py index d2834fe1e9..138f1e57b6 100644 --- a/python/samples/02-agents/providers/openai/openai_chat_client_basic.py +++ b/python/samples/02-agents/providers/openai/client_basic.py @@ -1,12 +1,14 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os from random import randint from typing import Annotated from agent_framework import Agent, tool from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv +from pydantic import Field # Load environment variables from .env file load_dotenv() @@ -14,17 +16,15 @@ load_dotenv() """ OpenAI Chat Client Basic Example -This sample demonstrates basic usage of OpenAIChatClient for direct chat-based -interactions, showing both streaming and non-streaming responses. +This sample demonstrates basic usage of OpenAIChatClient with explicit model and +API key settings, showing both streaming and non-streaming responses. """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; -# see samples/02-agents/tools/function_tool_with_approval.py -# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production. @tool(approval_mode="never_require") def get_weather( - location: Annotated[str, "The location to get the weather for."], + location: Annotated[str, Field(description="The location to get the weather for.")], ) -> str: """Get the weather for a given location.""" conditions = ["sunny", "cloudy", "rainy", "stormy"] @@ -36,13 +36,16 @@ async def non_streaming_example() -> None: print("=== Non-streaming Response Example ===") agent = Agent( - client=OpenAIChatClient(), + client=OpenAIChatClient( + model="gpt-5.4-nano", + api_key=os.getenv("OPENAI_API_KEY"), + ), name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather, ) - query = "What's the weather like in Seattle?" + query = "What's the weather in Seattle?" print(f"User: {query}") result = await agent.run(query) print(f"Result: {result}\n") @@ -53,13 +56,16 @@ async def streaming_example() -> None: print("=== Streaming Response Example ===") agent = Agent( - client=OpenAIChatClient(), + client=OpenAIChatClient( + model="gpt-5.4-nano", + api_key=os.getenv("OPENAI_API_KEY"), + ), name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather, ) - query = "What's the weather like in Portland?" + query = "What's the weather in Portland?" print(f"User: {query}") print("Agent: ", end="", flush=True) async for chunk in agent.run(query, stream=True): diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_image_analysis.py b/python/samples/02-agents/providers/openai/client_image_analysis.py similarity index 70% rename from python/samples/02-agents/providers/openai/openai_responses_client_image_analysis.py rename to python/samples/02-agents/providers/openai/client_image_analysis.py index 82fee38455..00c0207528 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_image_analysis.py +++ b/python/samples/02-agents/providers/openai/client_image_analysis.py @@ -3,26 +3,26 @@ import asyncio from agent_framework import Agent, Content -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Responses Client Image Analysis Example +OpenAI Chat Client Image Analysis Example -This sample demonstrates using OpenAI Responses Client for image analysis and vision tasks, +This sample demonstrates using OpenAI Chat Client for image analysis and vision tasks, showing multi-modal content handling with text and images. """ async def main(): - print("=== OpenAI Responses Agent with Image Analysis ===") + print("=== OpenAI Chat Client Agent with Image Analysis ===") - # 1. Create an OpenAI Responses agent with vision capabilities + # 1. Create an OpenAI Chat agent with vision capabilities agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="VisionAgent", instructions="You are a image analysist, you get a image and need to respond with what you see in the picture.", ) diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_image_generation.py b/python/samples/02-agents/providers/openai/client_image_generation.py similarity index 90% rename from python/samples/02-agents/providers/openai/openai_responses_client_image_generation.py rename to python/samples/02-agents/providers/openai/client_image_generation.py index 6e01a4dbbd..84e50674d4 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_image_generation.py +++ b/python/samples/02-agents/providers/openai/client_image_generation.py @@ -7,17 +7,17 @@ import urllib.request as urllib_request from pathlib import Path from agent_framework import Agent, Content -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Responses Client Image Generation Example +OpenAI Chat Client Image Generation Example This sample demonstrates how to generate images using OpenAI's DALL-E models -through the Responses Client. Image generation capabilities enable AI to create visual content from text, +through the Chat Client. Image generation capabilities enable AI to create visual content from text, making it ideal for creative applications, content creation, design prototyping, and automated visual asset generation. """ @@ -57,10 +57,10 @@ def save_image(output: Content) -> None: async def main() -> None: - print("=== OpenAI Responses Image Generation Agent Example ===") + print("=== OpenAI Chat Image Generation Agent Example ===") # Create an agent with customized image generation options - client = OpenAIResponsesClient() + client = OpenAIChatClient() agent = Agent( client=client, instructions="You are a helpful AI that can generate images.", diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_reasoning.py b/python/samples/02-agents/providers/openai/client_reasoning.py similarity index 91% rename from python/samples/02-agents/providers/openai/openai_responses_client_reasoning.py rename to python/samples/02-agents/providers/openai/client_reasoning.py index a4fc3849b8..2eea8d2106 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_reasoning.py +++ b/python/samples/02-agents/providers/openai/client_reasoning.py @@ -3,14 +3,14 @@ import asyncio from agent_framework import Agent -from agent_framework.openai import OpenAIResponsesClient, OpenAIResponsesOptions +from agent_framework.openai import OpenAIChatClient, OpenAIChatOptions from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Responses Client Reasoning Example +OpenAI Chat Client Reasoning Example This sample demonstrates advanced reasoning capabilities using OpenAI's gpt-5 models, showing step-by-step reasoning process visualization and complex problem-solving. @@ -25,7 +25,7 @@ In this case they are here: https://platform.openai.com/docs/api-reference/respo agent = Agent( - client=OpenAIResponsesClient[OpenAIResponsesOptions](model_id="gpt-5"), + client=OpenAIChatClient[OpenAIChatOptions](model_id="gpt-5"), name="MathHelper", instructions="You are a personal math tutor. When asked a math question, " "reason over how best to approach the problem and share your thought process.", @@ -76,7 +76,7 @@ async def streaming_reasoning_example() -> None: async def main() -> None: - print("\033[92m=== Basic OpenAI Responses Reasoning Agent Example ===\033[0m") + print("\033[92m=== Basic OpenAI Chat Reasoning Agent Example ===\033[0m") await reasoning_example() await streaming_reasoning_example() diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_streaming_image_generation.py b/python/samples/02-agents/providers/openai/client_streaming_image_generation.py similarity index 96% rename from python/samples/02-agents/providers/openai/openai_responses_client_streaming_image_generation.py rename to python/samples/02-agents/providers/openai/client_streaming_image_generation.py index 7aafd6f704..412e6f8e6a 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_streaming_image_generation.py +++ b/python/samples/02-agents/providers/openai/client_streaming_image_generation.py @@ -7,12 +7,12 @@ from pathlib import Path import anyio from agent_framework import Agent, Content -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() -"""OpenAI Responses Client Streaming Image Generation Example +"""OpenAI Chat Client Streaming Image Generation Example Demonstrates streaming partial image generation using OpenAI's image generation tool. Shows progressive image rendering with partial images for improved user experience. Note: The number of partial images received depends on generation speed: @@ -42,7 +42,7 @@ async def main(): """Demonstrate streaming image generation with partial images.""" print("=== OpenAI Streaming Image Generation Example ===\n") # Create agent with streaming image generation enabled - client = OpenAIResponsesClient() + client = OpenAIChatClient() agent = Agent( client=client, instructions="You are a helpful agent that can generate images.", diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_agent_as_tool.py b/python/samples/02-agents/providers/openai/client_with_agent_as_tool.py similarity index 90% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_agent_as_tool.py rename to python/samples/02-agents/providers/openai/client_with_agent_as_tool.py index 567c7fcaef..d8a991242d 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_agent_as_tool.py +++ b/python/samples/02-agents/providers/openai/client_with_agent_as_tool.py @@ -4,14 +4,14 @@ import asyncio from collections.abc import Awaitable, Callable from agent_framework import Agent, FunctionInvocationContext -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Responses Client Agent-as-Tool Example +OpenAI Chat Client Agent-as-Tool Example Demonstrates hierarchical agent architectures where one agent delegates work to specialized sub-agents wrapped as tools using as_tool(). @@ -35,9 +35,9 @@ async def logging_middleware( async def main() -> None: - print("=== OpenAI Responses Client Agent-as-Tool Pattern ===") + print("=== OpenAI Chat Client Agent-as-Tool Pattern ===") - client = OpenAIResponsesClient() + client = OpenAIChatClient() # Create a specialized writer agent writer = Agent( diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_code_interpreter.py b/python/samples/02-agents/providers/openai/client_with_code_interpreter.py similarity index 85% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_code_interpreter.py rename to python/samples/02-agents/providers/openai/client_with_code_interpreter.py index c3a8eba82a..f318c29419 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_code_interpreter.py +++ b/python/samples/02-agents/providers/openai/client_with_code_interpreter.py @@ -6,25 +6,25 @@ from agent_framework import ( Agent, Content, ) -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Responses Client with Code Interpreter Example +OpenAI Chat Client with Code Interpreter Example -This sample demonstrates using get_code_interpreter_tool() with OpenAI Responses Client +This sample demonstrates using get_code_interpreter_tool() with OpenAI Chat Client for Python code execution and mathematical problem solving. """ async def main() -> None: - """Example showing how to use the code interpreter tool with OpenAI Responses.""" - print("=== OpenAI Responses Agent with Code Interpreter Example ===") + """Example showing how to use the code interpreter tool with OpenAI Chat.""" + print("=== OpenAI Chat Client Agent with Code Interpreter Example ===") - client = OpenAIResponsesClient() + client = OpenAIChatClient() agent = Agent( client=client, instructions="You are a helpful assistant that can write and execute Python code to solve problems.", diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_code_interpreter_files.py b/python/samples/02-agents/providers/openai/client_with_code_interpreter_files.py similarity index 92% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_code_interpreter_files.py rename to python/samples/02-agents/providers/openai/client_with_code_interpreter_files.py index 1636a22912..c286feff10 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_code_interpreter_files.py +++ b/python/samples/02-agents/providers/openai/client_with_code_interpreter_files.py @@ -5,7 +5,7 @@ import os import tempfile from agent_framework import Agent -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv from openai import AsyncOpenAI @@ -13,9 +13,9 @@ from openai import AsyncOpenAI load_dotenv() """ -OpenAI Responses Client with Code Interpreter and Files Example +OpenAI Chat Client with Code Interpreter and Files Example -This sample demonstrates using get_code_interpreter_tool() with OpenAI Responses Client +This sample demonstrates using get_code_interpreter_tool() with OpenAI Chat Client for Python code execution and data analysis with uploaded files. """ @@ -69,8 +69,8 @@ async def main() -> None: temp_file_path, file_id = await create_sample_file_and_upload(openai_client) - # Create agent using OpenAI Responses client - client = OpenAIResponsesClient() + # Create agent using OpenAI Chat client + client = OpenAIChatClient() agent = Agent( client=client, instructions="You are a helpful assistant that can analyze data files using Python code.", diff --git a/python/samples/02-agents/providers/openai/openai_chat_client_with_explicit_settings.py b/python/samples/02-agents/providers/openai/client_with_explicit_settings.py similarity index 100% rename from python/samples/02-agents/providers/openai/openai_chat_client_with_explicit_settings.py rename to python/samples/02-agents/providers/openai/client_with_explicit_settings.py diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_file_search.py b/python/samples/02-agents/providers/openai/client_with_file_search.py similarity index 85% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_file_search.py rename to python/samples/02-agents/providers/openai/client_with_file_search.py index b6c9ac352f..042d888dff 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_file_search.py +++ b/python/samples/02-agents/providers/openai/client_with_file_search.py @@ -3,23 +3,23 @@ import asyncio from agent_framework import Agent -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Responses Client with File Search Example +OpenAI Chat Client with File Search Example -This sample demonstrates using get_file_search_tool() with OpenAI Responses Client +This sample demonstrates using get_file_search_tool() with OpenAI Chat Client for direct document-based question answering and information retrieval. """ # Helper functions -async def create_vector_store(client: OpenAIResponsesClient) -> tuple[str, str]: +async def create_vector_store(client: OpenAIChatClient) -> tuple[str, str]: """Create a vector store with sample documents.""" file = await client.client.files.create( file=("todays_weather.txt", b"The weather today is sunny with a high of 75F."), purpose="user_data" @@ -35,14 +35,14 @@ async def create_vector_store(client: OpenAIResponsesClient) -> tuple[str, str]: return file.id, vector_store.id -async def delete_vector_store(client: OpenAIResponsesClient, file_id: str, vector_store_id: str) -> None: +async def delete_vector_store(client: OpenAIChatClient, file_id: str, vector_store_id: str) -> None: """Delete the vector store after using it.""" await client.client.vector_stores.delete(vector_store_id=vector_store_id) await client.client.files.delete(file_id=file_id) async def main() -> None: - client = OpenAIResponsesClient() + client = OpenAIChatClient() message = "What is the weather today? Do a file search to find the answer." diff --git a/python/samples/02-agents/providers/openai/openai_chat_client_with_function_tools.py b/python/samples/02-agents/providers/openai/client_with_function_tools.py similarity index 100% rename from python/samples/02-agents/providers/openai/openai_chat_client_with_function_tools.py rename to python/samples/02-agents/providers/openai/client_with_function_tools.py diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_hosted_mcp.py b/python/samples/02-agents/providers/openai/client_with_hosted_mcp.py similarity index 95% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_hosted_mcp.py rename to python/samples/02-agents/providers/openai/client_with_hosted_mcp.py index 45e7ae736a..ffcdadb8da 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_hosted_mcp.py +++ b/python/samples/02-agents/providers/openai/client_with_hosted_mcp.py @@ -4,7 +4,7 @@ import asyncio from typing import TYPE_CHECKING, Any from agent_framework import Agent -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv if TYPE_CHECKING: @@ -14,10 +14,10 @@ if TYPE_CHECKING: load_dotenv() """ -OpenAI Responses Client with Hosted MCP Example +OpenAI Chat Client with Hosted MCP Example This sample demonstrates integrating hosted Model Context Protocol (MCP) tools with -OpenAI Responses Client, including user approval workflows for function call security. +OpenAI Chat Client, including user approval workflows for function call security. """ @@ -102,7 +102,7 @@ async def run_hosted_mcp_without_session_and_specific_approval() -> None: """Example showing Mcp Tools with approvals without using a session.""" print("=== Mcp with approvals and without session ===") - client = OpenAIResponsesClient() + client = OpenAIChatClient() # Create MCP tool with specific approval mode mcp_tool = client.get_mcp_tool( name="Microsoft Learn MCP", @@ -135,7 +135,7 @@ async def run_hosted_mcp_without_approval() -> None: """Example showing Mcp Tools without approvals.""" print("=== Mcp without approvals ===") - client = OpenAIResponsesClient() + client = OpenAIChatClient() # Create MCP tool that never requires approval mcp_tool = client.get_mcp_tool( name="Microsoft Learn MCP", @@ -167,7 +167,7 @@ async def run_hosted_mcp_with_session() -> None: """Example showing Mcp Tools with approvals using a session.""" print("=== Mcp with approvals and with session ===") - client = OpenAIResponsesClient() + client = OpenAIChatClient() # Create MCP tool that always requires approval mcp_tool = client.get_mcp_tool( name="Microsoft Learn MCP", @@ -200,7 +200,7 @@ async def run_hosted_mcp_with_session_streaming() -> None: """Example showing Mcp Tools with approvals using a session.""" print("=== Mcp with approvals and with session ===") - client = OpenAIResponsesClient() + client = OpenAIChatClient() # Create MCP tool that always requires approval mcp_tool = client.get_mcp_tool( name="Microsoft Learn MCP", @@ -234,7 +234,7 @@ async def run_hosted_mcp_with_session_streaming() -> None: async def main() -> None: - print("=== OpenAI Responses Client Agent with Hosted Mcp Tools Examples ===\n") + print("=== OpenAI Chat Client Agent with Hosted Mcp Tools Examples ===\n") await run_hosted_mcp_without_approval() await run_hosted_mcp_without_session_and_specific_approval() diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_local_mcp.py b/python/samples/02-agents/providers/openai/client_with_local_mcp.py similarity index 90% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_local_mcp.py rename to python/samples/02-agents/providers/openai/client_with_local_mcp.py index 8f136021bb..f7b14a24b2 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_local_mcp.py +++ b/python/samples/02-agents/providers/openai/client_with_local_mcp.py @@ -3,17 +3,17 @@ import asyncio from agent_framework import Agent, MCPStreamableHTTPTool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Responses Client with Local MCP Example +OpenAI Chat Client with Local MCP Example This sample demonstrates integrating local Model Context Protocol (MCP) tools with -OpenAI Responses Client for direct response generation with external capabilities. +OpenAI Chat Client for direct response generation with external capabilities. """ @@ -27,7 +27,7 @@ async def streaming_with_mcp(show_raw_stream: bool = False) -> None: # Tools are provided when creating the agent # The agent can use these tools for any query during its lifetime async with Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="DocsAgent", instructions="You are a helpful assistant that can help with microsoft documentation questions.", tools=MCPStreamableHTTPTool( # Tools defined at agent creation @@ -65,7 +65,7 @@ async def run_with_mcp() -> None: # Tools are provided when creating the agent # The agent can use these tools for any query during its lifetime async with Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="DocsAgent", instructions="You are a helpful assistant that can help with microsoft documentation questions.", tools=MCPStreamableHTTPTool( # Tools defined at agent creation @@ -87,7 +87,7 @@ async def run_with_mcp() -> None: async def main() -> None: - print("=== OpenAI Responses Client Agent with Function Tools Examples ===\n") + print("=== OpenAI Chat Client Agent with Function Tools Examples ===\n") await run_with_mcp() await streaming_with_mcp() diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_local_shell.py b/python/samples/02-agents/providers/openai/client_with_local_shell.py similarity index 96% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_local_shell.py rename to python/samples/02-agents/providers/openai/client_with_local_shell.py index b3135702a7..f4829cc5e7 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_local_shell.py +++ b/python/samples/02-agents/providers/openai/client_with_local_shell.py @@ -5,14 +5,14 @@ import subprocess from typing import Any from agent_framework import Agent, Message, tool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Responses Client with Local Shell Tool Example +OpenAI Chat Client with Local Shell Tool Example This sample demonstrates implementing a local shell tool using get_shell_tool(func=...) that wraps Python's subprocess module. Unlike the hosted shell tool (get_shell_tool()), @@ -53,7 +53,7 @@ async def main() -> None: print("=== OpenAI Agent with Local Shell Tool Example ===") print("NOTE: Commands will execute on your local machine.\n") - client = OpenAIResponsesClient() + client = OpenAIChatClient() local_shell_tool = client.get_shell_tool( func=run_bash, ) diff --git a/python/samples/02-agents/providers/openai/openai_chat_client_with_runtime_json_schema.py b/python/samples/02-agents/providers/openai/client_with_runtime_json_schema.py similarity index 95% rename from python/samples/02-agents/providers/openai/openai_chat_client_with_runtime_json_schema.py rename to python/samples/02-agents/providers/openai/client_with_runtime_json_schema.py index ba21d0a325..3fcffeae6c 100644 --- a/python/samples/02-agents/providers/openai/openai_chat_client_with_runtime_json_schema.py +++ b/python/samples/02-agents/providers/openai/client_with_runtime_json_schema.py @@ -4,7 +4,7 @@ import asyncio import json from agent_framework import Agent -from agent_framework.openai import OpenAIChatClient, OpenAIChatOptions +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file @@ -38,7 +38,7 @@ async def non_streaming_example() -> None: print("=== Non-streaming runtime JSON schema example ===") agent = Agent( - client=OpenAIChatClient[OpenAIChatOptions](), + client=OpenAIChatClient(), name="RuntimeSchemaAgent", instructions="Return only JSON that matches the provided schema. Do not add commentary.", ) diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_session.py b/python/samples/02-agents/providers/openai/client_with_session.py similarity index 93% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_session.py rename to python/samples/02-agents/providers/openai/client_with_session.py index e62c3bdaea..0dffeaef6c 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_session.py +++ b/python/samples/02-agents/providers/openai/client_with_session.py @@ -5,7 +5,7 @@ from random import randint from typing import Annotated from agent_framework import Agent, AgentSession, tool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv from pydantic import Field @@ -13,9 +13,9 @@ from pydantic import Field load_dotenv() """ -OpenAI Responses Client with Session Management Example +OpenAI Chat Client with Session Management Example -This sample demonstrates session management with OpenAI Responses Client, showing +This sample demonstrates session management with OpenAI Chat Client, showing persistent conversation context and simplified response handling. """ @@ -37,7 +37,7 @@ async def example_with_automatic_session_creation() -> None: print("=== Automatic Session Creation Example ===") agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -64,7 +64,7 @@ async def example_with_session_persistence_in_memory() -> None: print("=== Session Persistence Example (In-Memory) ===") agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -103,7 +103,7 @@ async def example_with_existing_session_id() -> None: existing_session_id = None agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), instructions="You are a helpful weather agent.", tools=get_weather, ) @@ -124,7 +124,7 @@ async def example_with_existing_session_id() -> None: print("\n--- Continuing with the same session ID in a new agent instance ---") agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), instructions="You are a helpful weather agent.", tools=get_weather, ) diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_shell.py b/python/samples/02-agents/providers/openai/client_with_shell.py similarity index 82% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_shell.py rename to python/samples/02-agents/providers/openai/client_with_shell.py index b86f36fde5..5043d8e4a1 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_shell.py +++ b/python/samples/02-agents/providers/openai/client_with_shell.py @@ -3,16 +3,16 @@ import asyncio from agent_framework import Agent -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Responses Client with Shell Tool Example +OpenAI Chat Client with Shell Tool Example -This sample demonstrates using get_shell_tool() with OpenAI Responses Client +This sample demonstrates using get_shell_tool() with OpenAI Chat Client for executing shell commands in a managed container environment hosted by OpenAI. The shell tool allows the model to run commands like listing files, running scripts, @@ -21,10 +21,10 @@ or performing system operations within a secure, sandboxed container. async def main() -> None: - """Example showing how to use the shell tool with OpenAI Responses.""" - print("=== OpenAI Responses Agent with Shell Tool Example ===") + """Example showing how to use the shell tool with OpenAI Chat.""" + print("=== OpenAI Chat Client Agent with Shell Tool Example ===") - client = OpenAIResponsesClient() + client = OpenAIChatClient() # Create a hosted shell tool with the default auto container environment shell_tool = client.get_shell_tool() diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_structured_output.py b/python/samples/02-agents/providers/openai/client_with_structured_output.py similarity index 87% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_structured_output.py rename to python/samples/02-agents/providers/openai/client_with_structured_output.py index d2599c0bd8..57bade4412 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_structured_output.py +++ b/python/samples/02-agents/providers/openai/client_with_structured_output.py @@ -3,7 +3,7 @@ import asyncio from agent_framework import Agent, AgentResponse -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv from pydantic import BaseModel @@ -11,9 +11,9 @@ from pydantic import BaseModel load_dotenv() """ -OpenAI Responses Client with Structured Output Example +OpenAI Chat Client with Structured Output Example -This sample demonstrates using structured output capabilities with OpenAI Responses Client, +This sample demonstrates using structured output capabilities with OpenAI Chat Client, showing Pydantic model integration for type-safe response parsing and data extraction. """ @@ -28,9 +28,9 @@ class OutputStruct(BaseModel): async def non_streaming_example() -> None: print("=== Non-streaming example ===") - # Create an OpenAI Responses agent + # Create an OpenAI Chat agent agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="CityAgent", instructions="You are a helpful agent that describes cities in a structured format.", ) @@ -54,9 +54,9 @@ async def non_streaming_example() -> None: async def streaming_example() -> None: print("=== Streaming example ===") - # Create an OpenAI Responses agent + # Create an OpenAI Chat agent agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="CityAgent", instructions="You are a helpful agent that describes cities in a structured format.", ) @@ -82,7 +82,7 @@ async def streaming_example() -> None: async def main() -> None: - print("=== OpenAI Responses Agent with Structured Output ===") + print("=== OpenAI Chat Client Agent with Structured Output ===") await non_streaming_example() await streaming_example() diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_web_search.py b/python/samples/02-agents/providers/openai/client_with_web_search.py similarity index 88% rename from python/samples/02-agents/providers/openai/openai_responses_client_with_web_search.py rename to python/samples/02-agents/providers/openai/client_with_web_search.py index 9f22807ba9..d0bab5a87a 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_web_search.py +++ b/python/samples/02-agents/providers/openai/client_with_web_search.py @@ -3,22 +3,22 @@ import asyncio from agent_framework import Agent -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file load_dotenv() """ -OpenAI Responses Client with Web Search Example +OpenAI Chat Client with Web Search Example -This sample demonstrates using get_web_search_tool() with OpenAI Responses Client +This sample demonstrates using get_web_search_tool() with OpenAI Chat Client for direct real-time information retrieval and current data access. """ async def main() -> None: - client = OpenAIResponsesClient() + client = OpenAIChatClient() # Create web search tool with location context web_search_tool = client.get_web_search_tool( diff --git a/python/samples/02-agents/providers/openai/openai_assistants_basic.py b/python/samples/02-agents/providers/openai/openai_assistants_basic.py deleted file mode 100644 index 5901b6ef38..0000000000 --- a/python/samples/02-agents/providers/openai/openai_assistants_basic.py +++ /dev/null @@ -1,98 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os -from random import randint -from typing import Annotated - -from agent_framework import tool -from agent_framework.openai import OpenAIAssistantProvider -from dotenv import load_dotenv -from openai import AsyncOpenAI -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -OpenAI Assistants Basic Example - -This sample demonstrates basic usage of OpenAIAssistantProvider with automatic -assistant lifecycle management, showing both streaming and non-streaming responses. -""" - - -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; -# see samples/02-agents/tools/function_tool_with_approval.py -# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. -@tool(approval_mode="never_require") -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}C." - - -async def non_streaming_example() -> None: - """Example of non-streaming response (get the complete result at once).""" - print("=== Non-streaming Response Example ===") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - # Create a new assistant via the provider - agent = await provider.create_agent( - name="WeatherAssistant", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful weather agent.", - tools=[get_weather], - ) - - try: - query = "What's the weather like in Seattle?" - print(f"User: {query}") - result = await agent.run(query) - print(f"Agent: {result}\n") - finally: - # Clean up the assistant from OpenAI - await client.beta.assistants.delete(agent.id) - - -async def streaming_example() -> None: - """Example of streaming response (get results as they are generated).""" - print("=== Streaming Response Example ===") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - # Create a new assistant via the provider - agent = await provider.create_agent( - name="WeatherAssistant", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful weather agent.", - tools=[get_weather], - ) - - try: - query = "What's the weather like in Portland?" - print(f"User: {query}") - print("Agent: ", end="", flush=True) - async for chunk in agent.run(query, stream=True): - if chunk.text: - print(chunk.text, end="", flush=True) - print("\n") - finally: - # Clean up the assistant from OpenAI - await client.beta.assistants.delete(agent.id) - - -async def main() -> None: - print("=== Basic OpenAI Assistants Provider Example ===") - - await non_streaming_example() - await streaming_example() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_provider_methods.py b/python/samples/02-agents/providers/openai/openai_assistants_provider_methods.py deleted file mode 100644 index 0cc9d33f73..0000000000 --- a/python/samples/02-agents/providers/openai/openai_assistants_provider_methods.py +++ /dev/null @@ -1,158 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os -from random import randint -from typing import Annotated - -from agent_framework import Agent, tool -from agent_framework.openai import OpenAIAssistantProvider -from dotenv import load_dotenv -from openai import AsyncOpenAI -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -OpenAI Assistant Provider Methods Example - -This sample demonstrates the methods available on the OpenAIAssistantProvider class: -- create_agent(): Create a new assistant on the service -- get_agent(): Retrieve an existing assistant by ID -- as_agent(): Wrap an SDK Assistant object without making HTTP calls -""" - - -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; -# see samples/02-agents/tools/function_tool_with_approval.py -# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. -@tool(approval_mode="never_require") -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}C." - - -async def create_agent_example() -> None: - """Create a new assistant using provider.create_agent().""" - print("\n--- create_agent() ---") - - async with ( - AsyncOpenAI() as client, - OpenAIAssistantProvider(client) as provider, - ): - agent = await provider.create_agent( - name="WeatherAssistant", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful weather assistant.", - tools=[get_weather], - ) - - try: - print(f"Created: {agent.name} (ID: {agent.id})") - result = await agent.run("What's the weather in Seattle?") - print(f"Response: {result}") - finally: - await client.beta.assistants.delete(agent.id) - - -async def get_agent_example() -> None: - """Retrieve an existing assistant by ID using provider.get_agent().""" - print("\n--- get_agent() ---") - - async with ( - AsyncOpenAI() as client, - OpenAIAssistantProvider(client) as provider, - ): - # Create an assistant directly with SDK (simulating pre-existing assistant) - sdk_assistant = await client.beta.assistants.create( - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - name="ExistingAssistant", - instructions="You always respond with 'Hello!'", - ) - - try: - # Retrieve using provider - agent = await provider.get_agent(sdk_assistant.id) - print(f"Retrieved: {agent.name} (ID: {agent.id})") - - result = await agent.run("Hi there!") - print(f"Response: {result}") - finally: - await client.beta.assistants.delete(sdk_assistant.id) - - -async def as_agent_example() -> None: - """Wrap an SDK Assistant object using Agent(client=provider, ...).""" - print("\n--- as_agent() ---") - - async with ( - AsyncOpenAI() as client, - OpenAIAssistantProvider(client) as provider, - ): - # Create assistant using SDK - sdk_assistant = await client.beta.assistants.create( - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - name="WrappedAssistant", - instructions="You respond with poetry.", - ) - - try: - # Wrap synchronously (no HTTP call) - agent = Agent(client=provider, agent=sdk_assistant) - print(f"Wrapped: {agent.name} (ID: {agent.id})") - - result = await agent.run("Tell me about the sunset.") - print(f"Response: {result}") - finally: - await client.beta.assistants.delete(sdk_assistant.id) - - -async def multiple_agents_example() -> None: - """Create and manage multiple assistants with a single provider.""" - print("\n--- Multiple Agents ---") - - async with ( - AsyncOpenAI() as client, - OpenAIAssistantProvider(client) as provider, - ): - weather_agent = await provider.create_agent( - name="WeatherSpecialist", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a weather specialist.", - tools=[get_weather], - ) - - greeter_agent = await provider.create_agent( - name="GreeterAgent", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a friendly greeter.", - ) - - try: - print(f"Created: {weather_agent.name}, {greeter_agent.name}") - - greeting = await greeter_agent.run("Hello!") - print(f"Greeter: {greeting}") - - weather = await weather_agent.run("What's the weather in Tokyo?") - print(f"Weather: {weather}") - finally: - await client.beta.assistants.delete(weather_agent.id) - await client.beta.assistants.delete(greeter_agent.id) - - -async def main() -> None: - print("OpenAI Assistant Provider Methods") - - await create_agent_example() - await get_agent_example() - await as_agent_example() - await multiple_agents_example() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_with_code_interpreter.py b/python/samples/02-agents/providers/openai/openai_assistants_with_code_interpreter.py deleted file mode 100644 index 044804e3c5..0000000000 --- a/python/samples/02-agents/providers/openai/openai_assistants_with_code_interpreter.py +++ /dev/null @@ -1,81 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os - -from agent_framework import AgentResponseUpdate, ChatResponseUpdate -from agent_framework.openai import OpenAIAssistantProvider, OpenAIAssistantsClient -from dotenv import load_dotenv -from openai import AsyncOpenAI -from openai.types.beta.threads.runs import ( - CodeInterpreterToolCallDelta, - RunStepDelta, - RunStepDeltaEvent, - ToolCallDeltaObject, -) -from openai.types.beta.threads.runs.code_interpreter_tool_call_delta import CodeInterpreter - -# Load environment variables from .env file -load_dotenv() - -""" -OpenAI Assistants with Code Interpreter Example - -This sample demonstrates using get_code_interpreter_tool() with OpenAI Assistants -for Python code execution and mathematical problem solving. -""" - - -def get_code_interpreter_chunk(chunk: AgentResponseUpdate) -> str | None: - """Helper method to access code interpreter data.""" - if ( - isinstance(chunk.raw_representation, ChatResponseUpdate) - and isinstance(chunk.raw_representation.raw_representation, RunStepDeltaEvent) - and isinstance(chunk.raw_representation.raw_representation.delta, RunStepDelta) - and isinstance(chunk.raw_representation.raw_representation.delta.step_details, ToolCallDeltaObject) - and chunk.raw_representation.raw_representation.delta.step_details.tool_calls - ): - for tool_call in chunk.raw_representation.raw_representation.delta.step_details.tool_calls: - if ( - isinstance(tool_call, CodeInterpreterToolCallDelta) - and isinstance(tool_call.code_interpreter, CodeInterpreter) - and tool_call.code_interpreter.input is not None - ): - return tool_call.code_interpreter.input - return None - - -async def main() -> None: - """Example showing how to use the code interpreter tool with OpenAI Assistants.""" - print("=== OpenAI Assistants Provider with Code Interpreter Example ===") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - chat_client = OpenAIAssistantsClient(client=client) - - agent = await provider.create_agent( - name="CodeHelper", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful assistant that can write and execute Python code to solve problems.", - tools=[chat_client.get_code_interpreter_tool()], - ) - - try: - query = "Use code to get the factorial of 100?" - print(f"User: {query}") - print("Agent: ", end="", flush=True) - generated_code = "" - async for chunk in agent.run(query, stream=True): - if chunk.text: - print(chunk.text, end="", flush=True) - code_interpreter_chunk = get_code_interpreter_chunk(chunk) - if code_interpreter_chunk is not None: - generated_code += code_interpreter_chunk - - print(f"\nGenerated code:\n{generated_code}") - finally: - await client.beta.assistants.delete(agent.id) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_with_existing_assistant.py b/python/samples/02-agents/providers/openai/openai_assistants_with_existing_assistant.py deleted file mode 100644 index 563dbb38a4..0000000000 --- a/python/samples/02-agents/providers/openai/openai_assistants_with_existing_assistant.py +++ /dev/null @@ -1,118 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os -from random import randint -from typing import Annotated - -from agent_framework import Agent, tool -from agent_framework.openai import OpenAIAssistantProvider -from dotenv import load_dotenv -from openai import AsyncOpenAI -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -OpenAI Assistants with Existing Assistant Example - -This sample demonstrates working with pre-existing OpenAI Assistants -using the provider's get_agent() and as_agent() methods. -""" - - -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; -# see samples/02-agents/tools/function_tool_with_approval.py -# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. -@tool(approval_mode="never_require") -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}C." - - -async def example_get_agent_by_id() -> None: - """Example: Using get_agent() to retrieve an existing assistant by ID.""" - print("=== Get Existing Assistant by ID ===") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - # Create an assistant via SDK (simulating an existing assistant) - created_assistant = await client.beta.assistants.create( - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - name="WeatherAssistant", - tools=[ - { - "type": "function", - "function": { - "name": "get_weather", - "description": "Get the weather for a given location.", - "parameters": { - "type": "object", - "properties": {"location": {"type": "string", "description": "The location"}}, - "required": ["location"], - }, - }, - } - ], - ) - print(f"Created assistant: {created_assistant.id}") - - try: - # Use get_agent() to retrieve the existing assistant - agent = await provider.get_agent( - assistant_id=created_assistant.id, - tools=[get_weather], # Required: implementation for function tools - instructions="You are a helpful weather agent.", - ) - - result = await agent.run("What's the weather like in Tokyo?") - print(f"Agent: {result}\n") - finally: - await client.beta.assistants.delete(created_assistant.id) - print("Assistant deleted.\n") - - -async def example_as_agent_wrap_sdk_object() -> None: - """Example: Using as_agent() to wrap an existing SDK Assistant object.""" - print("=== Wrap Existing SDK Assistant Object ===") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - # Create and fetch an assistant via SDK - created_assistant = await client.beta.assistants.create( - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - name="SimpleAssistant", - instructions="You are a friendly assistant.", - ) - print(f"Created assistant: {created_assistant.id}") - - try: - # Use as_agent() to wrap the SDK object - agent = Agent( - client=provider, - agent=created_assistant, - instructions="You are an extremely helpful assistant. Be enthusiastic!", - ) - - result = await agent.run("Hello! What can you help me with?") - print(f"Agent: {result}\n") - finally: - await client.beta.assistants.delete(created_assistant.id) - print("Assistant deleted.\n") - - -async def main() -> None: - print("=== OpenAI Assistants Provider with Existing Assistant Examples ===\n") - - await example_get_agent_by_id() - await example_as_agent_wrap_sdk_object() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_with_explicit_settings.py b/python/samples/02-agents/providers/openai/openai_assistants_with_explicit_settings.py deleted file mode 100644 index d7adef004c..0000000000 --- a/python/samples/02-agents/providers/openai/openai_assistants_with_explicit_settings.py +++ /dev/null @@ -1,61 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os -from random import randint -from typing import Annotated - -from agent_framework import tool -from agent_framework.openai import OpenAIAssistantProvider -from dotenv import load_dotenv -from openai import AsyncOpenAI -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -OpenAI Assistants with Explicit Settings Example - -This sample demonstrates creating OpenAI Assistants with explicit configuration -settings rather than relying on environment variable defaults. -""" - - -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; -# see samples/02-agents/tools/function_tool_with_approval.py -# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. -@tool(approval_mode="never_require") -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}C." - - -async def main() -> None: - print("=== OpenAI Assistants Provider with Explicit Settings ===") - - # Create client with explicit API key - client = AsyncOpenAI(api_key=os.environ["OPENAI_API_KEY"]) - provider = OpenAIAssistantProvider(client) - - agent = await provider.create_agent( - name="WeatherAssistant", - model=os.environ["OPENAI_MODEL"], - instructions="You are a helpful weather agent.", - tools=[get_weather], - ) - - try: - query = "What's the weather like in New York?" - print(f"Query: {query}") - result = await agent.run(query) - print(f"Result: {result}\n") - finally: - await client.beta.assistants.delete(agent.id) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_with_file_search.py b/python/samples/02-agents/providers/openai/openai_assistants_with_file_search.py deleted file mode 100644 index ad67986d4e..0000000000 --- a/python/samples/02-agents/providers/openai/openai_assistants_with_file_search.py +++ /dev/null @@ -1,78 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os - -from agent_framework import Content -from agent_framework.openai import OpenAIAssistantProvider, OpenAIAssistantsClient -from dotenv import load_dotenv -from openai import AsyncOpenAI - -# Load environment variables from .env file -load_dotenv() - -""" -OpenAI Assistants with File Search Example - -This sample demonstrates using get_file_search_tool() with OpenAI Assistants -for document-based question answering and information retrieval. -""" - - -async def create_vector_store(client: AsyncOpenAI) -> tuple[str, Content]: - """Create a vector store with sample documents.""" - file = await client.files.create( - file=("todays_weather.txt", b"The weather today is sunny with a high of 75F."), purpose="user_data" - ) - vector_store = await client.vector_stores.create( - name="knowledge_base", - expires_after={"anchor": "last_active_at", "days": 1}, - ) - result = await client.vector_stores.files.create_and_poll(vector_store_id=vector_store.id, file_id=file.id) - if result.last_error is not None: - raise Exception(f"Vector store file processing failed with status: {result.last_error.message}") - - return file.id, Content.from_hosted_vector_store(vector_store_id=vector_store.id) - - -async def delete_vector_store(client: AsyncOpenAI, file_id: str, vector_store_id: str) -> None: - """Delete the vector store after using it.""" - await client.vector_stores.delete(vector_store_id=vector_store_id) - await client.files.delete(file_id=file_id) - - -async def main() -> None: - print("=== OpenAI Assistants Provider with File Search Example ===\n") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - chat_client = OpenAIAssistantsClient(client=client) - - agent = await provider.create_agent( - name="SearchAssistant", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful assistant that searches files in a knowledge base.", - tools=[chat_client.get_file_search_tool()], - ) - - try: - query = "What is the weather today? Do a file search to find the answer." - file_id, vector_store_content = await create_vector_store(client) - - print(f"User: {query}") - print("Agent: ", end="", flush=True) - async for chunk in agent.run( - query, - stream=True, - options={"tool_resources": {"file_search": {"vector_store_ids": [vector_store_content.vector_store_id]}}}, - ): - if chunk.text: - print(chunk.text, end="", flush=True) - - await delete_vector_store(client, file_id, vector_store_content.vector_store_id) - finally: - await client.beta.assistants.delete(agent.id) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_with_function_tools.py b/python/samples/02-agents/providers/openai/openai_assistants_with_function_tools.py deleted file mode 100644 index ffd64d9ca2..0000000000 --- a/python/samples/02-agents/providers/openai/openai_assistants_with_function_tools.py +++ /dev/null @@ -1,159 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os -from datetime import datetime, timezone -from random import randint -from typing import Annotated - -from agent_framework import tool -from agent_framework.openai import OpenAIAssistantProvider -from dotenv import load_dotenv -from openai import AsyncOpenAI -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -OpenAI Assistants with Function Tools Example - -This sample demonstrates function tool integration with OpenAI Assistants, -showing both agent-level and query-level tool configuration patterns. -""" - - -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; -# see samples/02-agents/tools/function_tool_with_approval.py -# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. -@tool(approval_mode="never_require") -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}C." - - -@tool(approval_mode="never_require") -def get_time() -> str: - """Get the current UTC time.""" - current_time = datetime.now(timezone.utc) - return f"The current UTC time is {current_time.strftime('%Y-%m-%d %H:%M:%S')}." - - -async def tools_on_agent_level() -> None: - """Example showing tools defined when creating the agent.""" - print("=== Tools Defined on Agent Level ===") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - # Tools are provided when creating the agent - # The agent can use these tools for any query during its lifetime - agent = await provider.create_agent( - name="InfoAssistant", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful assistant that can provide weather and time information.", - tools=[get_weather, get_time], # Tools defined at agent creation - ) - - try: - # First query - agent can use weather tool - query1 = "What's the weather like in New York?" - print(f"User: {query1}") - result1 = await agent.run(query1) - print(f"Agent: {result1}\n") - - # Second query - agent can use time tool - query2 = "What's the current UTC time?" - print(f"User: {query2}") - result2 = await agent.run(query2) - print(f"Agent: {result2}\n") - - # Third query - agent can use both tools if needed - query3 = "What's the weather in London and what's the current UTC time?" - print(f"User: {query3}") - result3 = await agent.run(query3) - print(f"Agent: {result3}\n") - finally: - await client.beta.assistants.delete(agent.id) - - -async def tools_on_run_level() -> None: - """Example showing tools passed to the run method.""" - print("=== Tools Passed to Run Method ===") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - # Agent created with base tools, additional tools can be passed at run time - agent = await provider.create_agent( - name="FlexibleAssistant", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful assistant.", - tools=[get_weather], # Base tool - ) - - try: - # First query using base weather tool - query1 = "What's the weather like in Seattle?" - print(f"User: {query1}") - result1 = await agent.run(query1) - print(f"Agent: {result1}\n") - - # Second query with additional time tool - query2 = "What's the current UTC time?" - print(f"User: {query2}") - result2 = await agent.run(query2, tools=[get_time]) # Additional tool for this query - print(f"Agent: {result2}\n") - - # Third query with both tools - query3 = "What's the weather in Chicago and what's the current UTC time?" - print(f"User: {query3}") - result3 = await agent.run(query3, tools=[get_time]) # Time tool adds to weather - print(f"Agent: {result3}\n") - finally: - await client.beta.assistants.delete(agent.id) - - -async def mixed_tools_example() -> None: - """Example showing both agent-level tools and run-method tools.""" - print("=== Mixed Tools Example (Agent + Run Method) ===") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - # Agent created with some base tools - agent = await provider.create_agent( - name="ComprehensiveAssistant", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a comprehensive assistant that can help with various information requests.", - tools=[get_weather], # Base tool available for all queries - ) - - try: - # Query using both agent tool and additional run-method tools - query = "What's the weather in Denver and what's the current UTC time?" - print(f"User: {query}") - - # Agent has access to get_weather (from creation) + additional tools from run method - result = await agent.run( - query, - tools=[get_time], # Additional tools for this specific query - ) - print(f"Agent: {result}\n") - finally: - await client.beta.assistants.delete(agent.id) - - -async def main() -> None: - print("=== OpenAI Assistants Provider with Function Tools Examples ===\n") - - await tools_on_agent_level() - await tools_on_run_level() - await mixed_tools_example() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_with_response_format.py b/python/samples/02-agents/providers/openai/openai_assistants_with_response_format.py deleted file mode 100644 index 740b36107d..0000000000 --- a/python/samples/02-agents/providers/openai/openai_assistants_with_response_format.py +++ /dev/null @@ -1,96 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os - -from agent_framework.openai import OpenAIAssistantProvider -from dotenv import load_dotenv -from openai import AsyncOpenAI -from pydantic import BaseModel, ConfigDict - -# Load environment variables from .env file -load_dotenv() - -""" -OpenAI Assistant Provider Response Format Example - -This sample demonstrates using OpenAIAssistantProvider with response_format -for structured outputs in two ways: -1. Setting default response_format at agent creation time (default_options) -2. Overriding response_format at runtime (options parameter in agent.run) -""" - - -class WeatherInfo(BaseModel): - """Structured weather information.""" - - location: str - temperature: int - conditions: str - recommendation: str - model_config = ConfigDict(extra="forbid") - - -class CityInfo(BaseModel): - """Structured city information.""" - - city_name: str - population: int - country: str - model_config = ConfigDict(extra="forbid") - - -async def main() -> None: - """Example of using response_format at creation time and runtime.""" - - async with ( - AsyncOpenAI() as client, - OpenAIAssistantProvider(client) as provider, - ): - # Create agent with default response_format (WeatherInfo) - agent = await provider.create_agent( - name="StructuredReporter", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="Return structured JSON based on the requested format.", - default_options={"response_format": WeatherInfo}, - ) - - try: - # Request 1: Uses default response_format from agent creation - print("--- Request 1: Using default response_format (WeatherInfo) ---") - query1 = "What's the weather like in Paris today?" - print(f"User: {query1}") - - result1 = await agent.run(query1) - - try: - weather = result1.value - print("Agent:") - print(f" Location: {weather.location}") - print(f" Temperature: {weather.temperature}") - print(f" Conditions: {weather.conditions}") - print(f" Recommendation: {weather.recommendation}") - except Exception: - print(f"Failed to parse response: {result1.text}") - - # Request 2: Override response_format at runtime with CityInfo - print("\n--- Request 2: Runtime override with CityInfo ---") - query2 = "Tell me about Tokyo." - print(f"User: {query2}") - - result2 = await agent.run(query2, options={"response_format": CityInfo}) - - try: - city = result2.value - print("Agent:") - print(f" City: {city.city_name}") - print(f" Population: {city.population}") - print(f" Country: {city.country}") - except Exception: - print(f"Failed to parse response: {result2.text}") - finally: - await client.beta.assistants.delete(agent.id) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_assistants_with_session.py b/python/samples/02-agents/providers/openai/openai_assistants_with_session.py deleted file mode 100644 index 2259c5638d..0000000000 --- a/python/samples/02-agents/providers/openai/openai_assistants_with_session.py +++ /dev/null @@ -1,172 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os -from random import randint -from typing import Annotated - -from agent_framework import AgentSession, tool -from agent_framework.openai import OpenAIAssistantProvider -from dotenv import load_dotenv -from openai import AsyncOpenAI -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -OpenAI Assistants with Session Management Example - -This sample demonstrates session management with OpenAI Assistants, showing -persistent conversation sessions and context preservation across interactions. -""" - - -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; -# see samples/02-agents/tools/function_tool_with_approval.py -# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. -@tool(approval_mode="never_require") -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}C." - - -async def example_with_automatic_session_creation() -> None: - """Example showing automatic session creation (service-managed session).""" - print("=== Automatic Session Creation Example ===") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - agent = await provider.create_agent( - name="WeatherAssistant", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful weather agent.", - tools=[get_weather], - ) - - try: - # First conversation - no session provided, will be created automatically - query1 = "What's the weather like in Seattle?" - print(f"User: {query1}") - result1 = await agent.run(query1) - print(f"Agent: {result1.text}") - - # Second conversation - still no session provided, will create another new session - query2 = "What was the last city I asked about?" - print(f"\nUser: {query2}") - result2 = await agent.run(query2) - print(f"Agent: {result2.text}") - print("Note: Each call creates a separate session, so the agent doesn't remember previous context.\n") - finally: - await client.beta.assistants.delete(agent.id) - - -async def example_with_session_persistence() -> None: - """Example showing session persistence across multiple conversations.""" - print("=== Session Persistence Example ===") - print("Using the same session across multiple conversations to maintain context.\n") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - agent = await provider.create_agent( - name="WeatherAssistant", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful weather agent.", - tools=[get_weather], - ) - - try: - # Create a new session that will be reused - session = agent.create_session() - - # First conversation - query1 = "What's the weather like in Tokyo?" - print(f"User: {query1}") - result1 = await agent.run(query1, session=session) - print(f"Agent: {result1.text}") - - # Second conversation using the same session - maintains context - query2 = "How about London?" - print(f"\nUser: {query2}") - result2 = await agent.run(query2, session=session) - print(f"Agent: {result2.text}") - - # Third conversation - agent should remember both previous cities - query3 = "Which of the cities I asked about has better weather?" - print(f"\nUser: {query3}") - result3 = await agent.run(query3, session=session) - print(f"Agent: {result3.text}") - print("Note: The agent remembers context from previous messages in the same session.\n") - finally: - await client.beta.assistants.delete(agent.id) - - -async def example_with_existing_session_id() -> None: - """Example showing how to work with an existing session ID from the service.""" - print("=== Existing Session ID Example ===") - print("Using a specific session ID to continue an existing conversation.\n") - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - # First, create a conversation and capture the session ID - existing_session_id = None - assistant_id = None - - agent = await provider.create_agent( - name="WeatherAssistant", - model=os.environ.get("OPENAI_MODEL", "gpt-4"), - instructions="You are a helpful weather agent.", - tools=[get_weather], - ) - assistant_id = agent.id - - try: - # Start a conversation and get the session ID - session = agent.create_session() - query1 = "What's the weather in Paris?" - print(f"User: {query1}") - result1 = await agent.run(query1, session=session) - print(f"Agent: {result1.text}") - - # The session ID is set after the first response - existing_session_id = session.service_session_id - print(f"Session ID: {existing_session_id}") - - if existing_session_id: - print("\n--- Continuing with the same session ID using get_agent ---") - - # Get the existing assistant by ID - agent2 = await provider.get_agent( - assistant_id=assistant_id, - tools=[get_weather], # Must provide function implementations - ) - - # Create a session with the existing ID - session = AgentSession(service_session_id=existing_session_id) - - query2 = "What was the last city I asked about?" - print(f"User: {query2}") - result2 = await agent2.run(query2, session=session) - print(f"Agent: {result2.text}") - print("Note: The agent continues the conversation from the previous session.\n") - finally: - if assistant_id: - await client.beta.assistants.delete(assistant_id) - - -async def main() -> None: - print("=== OpenAI Assistants Provider Session Management Examples ===\n") - - await example_with_automatic_session_creation() - await example_with_session_persistence() - await example_with_existing_session_id() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_basic.py b/python/samples/02-agents/providers/openai/openai_responses_client_basic.py deleted file mode 100644 index c615cf3252..0000000000 --- a/python/samples/02-agents/providers/openai/openai_responses_client_basic.py +++ /dev/null @@ -1,132 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -from collections.abc import Awaitable, Callable -from random import randint -from typing import Annotated - -from agent_framework import ( - Agent, - ChatContext, - ChatResponse, - Message, - MiddlewareTermination, - Role, - chat_middleware, - tool, -) -from agent_framework.openai import OpenAIResponsesClient -from dotenv import load_dotenv -from pydantic import Field - -# Load environment variables from .env file -load_dotenv() - -""" -OpenAI Responses Client Basic Example - -This sample demonstrates basic usage of OpenAIResponsesClient for structured -response generation, showing both streaming and non-streaming responses. -""" - - -@chat_middleware -async def security_and_override_middleware( - context: ChatContext, - call_next: Callable[[], Awaitable[None]], -) -> None: - """Function-based middleware that implements security filtering and response override.""" - print("[SecurityMiddleware] Processing input...") - - # Security check - block sensitive information - blocked_terms = ["password", "secret", "api_key", "token"] - - for message in context.messages: - if message.text: - message_lower = message.text.lower() - for term in blocked_terms: - if term in message_lower: - print(f"[SecurityMiddleware] BLOCKED: Found '{term}' in message") - - # Override the response instead of calling AI - context.result = ChatResponse( - messages=[ - Message( - role=Role.ASSISTANT, - text="I cannot process requests containing sensitive information. " - "Please rephrase your question without including passwords, secrets, or other " - "sensitive data.", - ) - ] - ) - - # Terminate middleware execution with the blocked response - raise MiddlewareTermination(result=context.result) - - # Continue to next middleware or AI execution - await call_next() - - print("[SecurityMiddleware] Response generated.") - print(type(context.result)) - - -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; -# see samples/02-agents/tools/function_tool_with_approval.py -# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. -@tool(approval_mode="never_require") -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - conditions = ["sunny", "cloudy", "rainy", "stormy"] - return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." - - -async def non_streaming_example() -> None: - """Example of non-streaming response (get the complete result at once).""" - print("=== Non-streaming Response Example ===") - - agent = Agent( - client=OpenAIResponsesClient(), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - query = "What's the weather like in Seattle?" - print(f"User: {query}") - result = await agent.run(query) - print(f"Result: {result}\n") - - -async def streaming_example() -> None: - """Example of streaming response (get results as they are generated).""" - print("=== Streaming Response Example ===") - - agent = Agent( - client=OpenAIResponsesClient( - middleware=[security_and_override_middleware], - ), - instructions="You are a helpful weather agent.", - tools=get_weather, - ) - - query = "What's the weather like in Portland?" - print(f"User: {query}") - print("Agent: ", end="", flush=True) - response = agent.run(query, stream=True) - async for chunk in response: - if chunk.text: - print(chunk.text, end="", flush=True) - print("\n") - print(f"Final Result: {await response.get_final_response()}") - - -async def main() -> None: - print("=== Basic OpenAI Responses Client Agent Example ===") - - await streaming_example() - await non_streaming_example() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/05-end-to-end/m365-agent/.env.example b/python/samples/05-end-to-end/m365-agent/.env.example index 3c21a9e91c..100c2bf69d 100644 --- a/python/samples/05-end-to-end/m365-agent/.env.example +++ b/python/samples/05-end-to-end/m365-agent/.env.example @@ -1,6 +1,6 @@ # OpenAI Configuration OPENAI_API_KEY= -OPENAI_CHAT_MODEL_ID= +OPENAI_CHAT_MODEL= # Agent 365 Agentic Authentication Configuration USE_ANONYMOUS_MODE= diff --git a/python/samples/05-end-to-end/m365-agent/README.md b/python/samples/05-end-to-end/m365-agent/README.md index ecd1e6f632..6962a53229 100644 --- a/python/samples/05-end-to-end/m365-agent/README.md +++ b/python/samples/05-end-to-end/m365-agent/README.md @@ -21,7 +21,7 @@ export USE_ANONYMOUS_MODE=True # set to false if using auth # OpenAI export OPENAI_API_KEY="..." -export OPENAI_CHAT_MODEL_ID="..." +export OPENAI_CHAT_MODEL="..." ``` ## Installing Dependencies diff --git a/python/samples/README.md b/python/samples/README.md index fa091b78bc..82a008504c 100644 --- a/python/samples/README.md +++ b/python/samples/README.md @@ -61,6 +61,16 @@ client = OpenAIChatClient(env_file_path="path/to/custom.env") This allows different clients to use different configuration files if needed. +For the generic OpenAI clients (`OpenAIChatClient` and `OpenAIChatCompletionClient`), routing +precedence is: + +1. Explicit Azure inputs such as `credential`, `azure_endpoint`, or `api_version` +2. `OPENAI_API_KEY` / explicit OpenAI API-key parameters +3. Azure environment fallback such as `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_API_KEY` + +If you keep both OpenAI and Azure variables in your shell, the generic clients stay on OpenAI until +you pass an explicit Azure input. + For the getting-started samples, you'll need at minimum: ```bash AZURE_AI_PROJECT_ENDPOINT="your-foundry-project-endpoint" From 6b47cdbf5254ddd4e676dd8a9f29256a17f53891 Mon Sep 17 00:00:00 2001 From: Giles Odigwe <79032838+giles17@users.noreply.github.com> Date: Fri, 27 Mar 2026 09:27:19 -0700 Subject: [PATCH 20/22] Python: Fix broken samples for GitHub Copilot, declarative, and Responses API (#4915) * Python: Fix broken samples for GitHub Copilot, declarative, and Responses API - Add missing on_permission_request handler to github_copilot_basic and github_copilot_with_session samples (required by copilot SDK) - Increase timeout for remote MCP query in github_copilot_with_mcp sample - Soften session isolation claim in github_copilot_with_session sample - Fix inline_yaml sample: pass project_endpoint via client_kwargs instead of relying on YAML connection block (AzureAIClient expects project_endpoint, not endpoint) - Handle raw JSON schemas in Responses client _convert_response_format so declarative outputSchema works with the Responses API Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Improve raw JSON schema detection heuristic and add tests - Broaden raw schema detection to handle anyOf, oneOf, allOf, $ref, $defs keywords and JSON Schema primitive types, not just 'properties' - Apply same raw schema handling to azure-ai _shared.py for consistency - Add unit tests for both openai and azure-ai response_format conversion Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agent_framework_azure_ai/_shared.py | 21 ++++++ python/packages/azure-ai/tests/test_shared.py | 26 ++++++++ .../agent_framework_openai/_chat_client.py | 21 ++++++ .../tests/openai/test_openai_chat_client.py | 65 +++++++++++++++++++ .../02-agents/declarative/inline_yaml.py | 14 ++-- .../github_copilot/github_copilot_basic.py | 18 +++++ .../github_copilot/github_copilot_with_mcp.py | 3 +- .../github_copilot_with_session.py | 21 +++++- 8 files changed, 181 insertions(+), 8 deletions(-) diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_shared.py b/python/packages/azure-ai/agent_framework_azure_ai/_shared.py index 7f5f770e36..220640d270 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_shared.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_shared.py @@ -571,4 +571,25 @@ def _convert_response_format(response_format: Mapping[str, Any]) -> dict[str, An if format_type in {"json_object", "text"}: return {"type": format_type} + # Handle raw JSON schemas (e.g. {"type": "object", "properties": {...}}) + # by wrapping them in the expected json_schema envelope. + # Detect by checking for JSON Schema primitive types or known schema keywords. + json_schema_keywords = {"properties", "anyOf", "oneOf", "allOf", "$ref", "$defs"} + json_schema_primitive_types = {"object", "array", "string", "number", "integer", "boolean", "null"} + if format_type in json_schema_primitive_types or ( + format_type is None and any(k in response_format for k in json_schema_keywords) + ): + schema = dict(response_format) + if schema.get("type") == "object" and "additionalProperties" not in schema: + schema["additionalProperties"] = False + # Pop title from schema since OpenAI strict mode rejects unknown keys; + # use it as the schema name in the envelope instead. + name = str(schema.pop("title", None) or "response") + return { + "type": "json_schema", + "name": name, + "schema": schema, + "strict": True, + } + raise IntegrationInvalidRequestException("Unsupported response_format provided for Azure AI client.") diff --git a/python/packages/azure-ai/tests/test_shared.py b/python/packages/azure-ai/tests/test_shared.py index 5672561194..845638ceee 100644 --- a/python/packages/azure-ai/tests/test_shared.py +++ b/python/packages/azure-ai/tests/test_shared.py @@ -404,6 +404,32 @@ def test_convert_response_format_json_schema_missing_schema_raises() -> None: _convert_response_format({"type": "json_schema", "json_schema": {}}) +def test_convert_response_format_raw_json_schema_with_properties() -> None: + """Test raw JSON schema with properties is wrapped in json_schema envelope.""" + result = _convert_response_format({"type": "object", "properties": {"x": {"type": "string"}}, "title": "MyOutput"}) + + assert result["type"] == "json_schema" + assert result["name"] == "MyOutput" + assert result["strict"] is True + assert result["schema"]["additionalProperties"] is False + assert "title" not in result["schema"] + + +def test_convert_response_format_raw_json_schema_no_title() -> None: + """Test raw JSON schema without title defaults name to 'response'.""" + result = _convert_response_format({"type": "object", "properties": {"x": {"type": "string"}}}) + + assert result["name"] == "response" + + +def test_convert_response_format_raw_json_schema_with_anyof() -> None: + """Test raw JSON schema with anyOf keyword is detected.""" + result = _convert_response_format({"anyOf": [{"type": "string"}, {"type": "number"}]}) + + assert result["type"] == "json_schema" + assert result["strict"] is True + + def test_from_azure_ai_tools_mcp_approval_mode_always() -> None: """Test from_azure_ai_tools converts MCP require_approval='always' to dict.""" tools = [ diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index b0d56ee26f..54322cb754 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -636,6 +636,27 @@ class RawOpenAIChatClient( # type: ignore[misc] if format_type in {"json_object", "text"}: return {"type": format_type} + # Handle raw JSON schemas (e.g. {"type": "object", "properties": {...}}) + # by wrapping them in the expected json_schema envelope. + # Detect by checking for JSON Schema primitive types or known schema keywords. + json_schema_keywords = {"properties", "anyOf", "oneOf", "allOf", "$ref", "$defs"} + json_schema_primitive_types = {"object", "array", "string", "number", "integer", "boolean", "null"} + if format_type in json_schema_primitive_types or ( + format_type is None and any(k in response_format for k in json_schema_keywords) + ): + schema = dict(response_format) + if schema.get("type") == "object" and "additionalProperties" not in schema: + schema["additionalProperties"] = False + # Pop title from schema since OpenAI strict mode rejects unknown keys; + # use it as the schema name in the envelope instead. + name = str(schema.pop("title", None) or "response") + return { + "type": "json_schema", + "name": name, + "schema": schema, + "strict": True, + } + raise ChatClientInvalidRequestException("Unsupported response_format provided for Responses client.") def _get_conversation_id( diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index 3c09839594..d4e7b6c4a1 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -1713,6 +1713,71 @@ def test_response_format_json_schema_missing_schema() -> None: client._prepare_response_and_text_format(response_format=response_format, text_config=None) +def test_response_format_raw_json_schema_with_properties() -> None: + """Test raw JSON schema with properties is wrapped in json_schema envelope.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + response_format = {"type": "object", "properties": {"x": {"type": "string"}}, "title": "MyOutput"} + + _, text_config = client._prepare_response_and_text_format(response_format=response_format, text_config=None) + + assert text_config is not None + fmt = text_config["format"] + assert fmt["type"] == "json_schema" + assert fmt["name"] == "MyOutput" + assert fmt["strict"] is True + assert fmt["schema"]["additionalProperties"] is False + assert "title" not in fmt["schema"] + + +def test_response_format_raw_json_schema_no_title() -> None: + """Test raw JSON schema without title defaults name to 'response'.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + response_format = {"type": "object", "properties": {"x": {"type": "string"}}} + + _, text_config = client._prepare_response_and_text_format(response_format=response_format, text_config=None) + + assert text_config is not None + assert text_config["format"]["name"] == "response" + + +def test_response_format_raw_json_schema_preserves_additional_properties() -> None: + """Test raw JSON schema preserves existing additionalProperties.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + response_format = {"type": "object", "properties": {"x": {"type": "string"}}, "additionalProperties": True} + + _, text_config = client._prepare_response_and_text_format(response_format=response_format, text_config=None) + + assert text_config is not None + assert text_config["format"]["schema"]["additionalProperties"] is True + + +def test_response_format_raw_json_schema_non_object_type() -> None: + """Test raw JSON schema with non-object type does not inject additionalProperties.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + response_format = {"type": "array", "items": {"type": "string"}} + + _, text_config = client._prepare_response_and_text_format(response_format=response_format, text_config=None) + + assert text_config is not None + assert "additionalProperties" not in text_config["format"]["schema"] + + +def test_response_format_raw_json_schema_with_anyof() -> None: + """Test raw JSON schema with anyOf keyword is detected.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + response_format = {"anyOf": [{"type": "string"}, {"type": "number"}]} + + _, text_config = client._prepare_response_and_text_format(response_format=response_format, text_config=None) + + assert text_config is not None + assert text_config["format"]["type"] == "json_schema" + + def test_response_format_unsupported_type() -> None: """Test unsupported response_format type raises error.""" client = OpenAIChatClient(model="test-model", api_key="test-key") diff --git a/python/samples/02-agents/declarative/inline_yaml.py b/python/samples/02-agents/declarative/inline_yaml.py index b6d4c9ede4..016f75947f 100644 --- a/python/samples/02-agents/declarative/inline_yaml.py +++ b/python/samples/02-agents/declarative/inline_yaml.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import os from agent_framework.declarative import AgentFactory from azure.identity.aio import AzureCliCredential @@ -31,16 +32,17 @@ description: A agent that performs diagnostics on systems and can escalate issue model: id: =Env.AZURE_OPENAI_MODEL - connection: - kind: remote - endpoint: =Env.FOUNDRY_PROJECT_ENDPOINT """ # create the agent from the yaml async with ( AzureCliCredential() as credential, - AgentFactory(client_kwargs={"credential": credential}, safe_mode=False).create_agent_from_yaml( - yaml_definition - ) as agent, + AgentFactory( + client_kwargs={ + "credential": credential, + "project_endpoint": os.environ["FOUNDRY_PROJECT_ENDPOINT"], + }, + safe_mode=False, + ).create_agent_from_yaml(yaml_definition) as agent, ): response = await agent.run("What can you do for me?") print("Agent response:", response.text) diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_basic.py b/python/samples/02-agents/providers/github_copilot/github_copilot_basic.py index 3a2c4c8eec..af59ae9b3b 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_basic.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_basic.py @@ -19,6 +19,8 @@ from typing import Annotated from agent_framework import tool from agent_framework.github import GitHubCopilotAgent +from copilot.generated.session_events import PermissionRequest +from copilot.types import PermissionRequestResult from dotenv import load_dotenv from pydantic import Field @@ -26,6 +28,19 @@ from pydantic import Field load_dotenv() +def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: + """Permission handler that prompts the user for approval.""" + print(f"\n[Permission Request: {request.kind}]") + + if request.full_command_text is not None: + print(f" Command: {request.full_command_text}") + + response = input("Approve? (y/n): ").strip().lower() + if response in ("y", "yes"): + return PermissionRequestResult(kind="approved") + return PermissionRequestResult(kind="denied-interactively-by-user") + + # NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; # see samples/02-agents/tools/function_tool_with_approval.py # and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @@ -45,6 +60,7 @@ async def non_streaming_example() -> None: agent = GitHubCopilotAgent( instructions="You are a helpful weather agent.", tools=[get_weather], + default_options={"on_permission_request": prompt_permission}, ) async with agent: @@ -61,6 +77,7 @@ async def streaming_example() -> None: agent = GitHubCopilotAgent( instructions="You are a helpful weather agent.", tools=[get_weather], + default_options={"on_permission_request": prompt_permission}, ) async with agent: @@ -80,6 +97,7 @@ async def runtime_options_example() -> None: agent = GitHubCopilotAgent( instructions="Always respond in exactly 3 words.", tools=[get_weather], + default_options={"on_permission_request": prompt_permission}, ) async with agent: diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_mcp.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_mcp.py index fde1e8b72e..081dd1b21f 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_mcp.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_mcp.py @@ -69,9 +69,10 @@ async def main() -> None: print(f"Agent: {result1}\n") # Query that exercises the remote Microsoft Learn MCP server + # Remote MCP calls may take longer, so increase the timeout query2 = "Search Microsoft Learn for 'Azure Functions Python' and summarize the top result" print(f"User: {query2}") - result2 = await agent.run(query2) + result2 = await agent.run(query2, options={"timeout": 120}) print(f"Agent: {result2}\n") diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_session.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_session.py index 644104dc35..758eccb015 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_session.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_session.py @@ -14,9 +14,24 @@ from typing import Annotated from agent_framework import tool from agent_framework.github import GitHubCopilotAgent +from copilot.generated.session_events import PermissionRequest +from copilot.types import PermissionRequestResult from pydantic import Field +def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: + """Permission handler that prompts the user for approval.""" + print(f"\n[Permission Request: {request.kind}]") + + if request.full_command_text is not None: + print(f" Command: {request.full_command_text}") + + response = input("Approve? (y/n): ").strip().lower() + if response in ("y", "yes"): + return PermissionRequestResult(kind="approved") + return PermissionRequestResult(kind="denied-interactively-by-user") + + # NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; # see samples/02-agents/tools/function_tool_with_approval.py # and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @@ -36,6 +51,7 @@ async def example_with_automatic_session_creation() -> None: agent = GitHubCopilotAgent( instructions="You are a helpful weather agent.", tools=[get_weather], + default_options={"on_permission_request": prompt_permission}, ) async with agent: @@ -50,7 +66,7 @@ async def example_with_automatic_session_creation() -> None: print(f"\nUser: {query2}") result2 = await agent.run(query2) print(f"Agent: {result2}") - print("Note: Each call creates a separate session, so the agent doesn't remember previous context.\n") + print("Note: Each call creates a separate session, so the agent may not remember previous context.\n") async def example_with_session_persistence() -> None: @@ -60,6 +76,7 @@ async def example_with_session_persistence() -> None: agent = GitHubCopilotAgent( instructions="You are a helpful weather agent.", tools=[get_weather], + default_options={"on_permission_request": prompt_permission}, ) async with agent: @@ -96,6 +113,7 @@ async def example_with_existing_session_id() -> None: agent1 = GitHubCopilotAgent( instructions="You are a helpful weather agent.", tools=[get_weather], + default_options={"on_permission_request": prompt_permission}, ) async with agent1: @@ -117,6 +135,7 @@ async def example_with_existing_session_id() -> None: agent2 = GitHubCopilotAgent( instructions="You are a helpful weather agent.", tools=[get_weather], + default_options={"on_permission_request": prompt_permission}, ) async with agent2: From ca6cdd142e7f1c80edd581b2dddf26801253683c Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:38:23 +0000 Subject: [PATCH 21/22] .NET: Fixes for durable agents integration tests (#4952) * Fixing for durable agents integration tests * Add further fixes --- .../06_LongRunningTools/FunctionTriggers.cs | 11 ++++++----- .../ConsoleApps/06_LongRunningTools/Program.cs | 11 ++++++----- .../ConsoleApps/07_ReliableStreaming/Program.cs | 1 + .../SamplesValidation.cs | 2 +- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/FunctionTriggers.cs b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/FunctionTriggers.cs index ed66be8bdd..b707240ba8 100644 --- a/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/FunctionTriggers.cs +++ b/dotnet/samples/04-hosting/DurableAgents/AzureFunctions/06_LongRunningTools/FunctionTriggers.cs @@ -35,13 +35,15 @@ public static class FunctionTriggers int iterationCount = 0; while (iterationCount++ < input.MaxReviewAttempts) { + // NOTE: CustomStatus has a 16 KB UTF-16 limit in Durable Functions. + // Only include short metadata here - the full content is passed via activity inputs/outputs. context.SetCustomStatus( new { message = "Requesting human feedback.", approvalTimeoutHours = input.ApprovalTimeoutHours, iterationCount, - content + contentTitle = content.Title, }); // Step 2: Notify user to review the content @@ -63,7 +65,6 @@ public static class FunctionTriggers { message = $"Human approval timed out after {input.ApprovalTimeoutHours} hour(s). Treating as rejection.", iterationCount, - content }); throw new TimeoutException($"Human approval timed out after {input.ApprovalTimeoutHours} hour(s)."); } @@ -73,7 +74,7 @@ public static class FunctionTriggers context.SetCustomStatus(new { message = "Content approved by human reviewer. Publishing content...", - content + contentTitle = content.Title, }); // Step 4: Publish the approved content @@ -83,7 +84,7 @@ public static class FunctionTriggers { message = $"Content published successfully at {context.CurrentUtcDateTime:s}", humanFeedback = humanResponse, - content + contentTitle = content.Title, }); return new { content = content.Content }; } @@ -92,7 +93,7 @@ public static class FunctionTriggers { message = "Content rejected by human reviewer. Incorporating feedback and regenerating...", humanFeedback = humanResponse, - content + contentTitle = content.Title, }); // Incorporate human feedback and regenerate diff --git a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/06_LongRunningTools/Program.cs b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/06_LongRunningTools/Program.cs index 203edca308..7bb593ab29 100644 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/06_LongRunningTools/Program.cs +++ b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/06_LongRunningTools/Program.cs @@ -77,13 +77,15 @@ static async Task RunOrchestratorAsync(TaskOrchestrationContext context, int iterationCount = 0; while (iterationCount++ < input.MaxReviewAttempts) { + // NOTE: CustomStatus has a 16 KB UTF-16 limit in Durable Functions. + // Only include short metadata here - the full content is passed via activity inputs/outputs. context.SetCustomStatus( new { message = "Requesting human feedback.", approvalTimeoutHours = input.ApprovalTimeoutHours, iterationCount, - content + contentTitle = content.Title, }); // Step 2: Notify user to review the content @@ -105,7 +107,6 @@ static async Task RunOrchestratorAsync(TaskOrchestrationContext context, { message = $"Human approval timed out after {input.ApprovalTimeoutHours} hour(s). Treating as rejection.", iterationCount, - content }); throw new TimeoutException($"Human approval timed out after {input.ApprovalTimeoutHours} hour(s)."); } @@ -115,7 +116,7 @@ static async Task RunOrchestratorAsync(TaskOrchestrationContext context, context.SetCustomStatus(new { message = "Content approved by human reviewer. Publishing content...", - content + contentTitle = content.Title, }); // Step 4: Publish the approved content @@ -125,7 +126,7 @@ static async Task RunOrchestratorAsync(TaskOrchestrationContext context, { message = $"Content published successfully at {context.CurrentUtcDateTime:s}", humanFeedback = humanResponse, - content + contentTitle = content.Title, }); return new { content = content.Content }; } @@ -134,7 +135,7 @@ static async Task RunOrchestratorAsync(TaskOrchestrationContext context, { message = "Content rejected by human reviewer. Incorporating feedback and regenerating...", humanFeedback = humanResponse, - content + contentTitle = content.Title, }); // Incorporate human feedback and regenerate diff --git a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/07_ReliableStreaming/Program.cs b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/07_ReliableStreaming/Program.cs index 3abc5c8701..9be3f4f659 100644 --- a/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/07_ReliableStreaming/Program.cs +++ b/dotnet/samples/04-hosting/DurableAgents/ConsoleApps/07_ReliableStreaming/Program.cs @@ -285,6 +285,7 @@ async Task ReadStreamTask(string conversationId, string? cursor, CancellationTok if (chunk.Text != null) { Console.Write(chunk.Text); + Console.Out.Flush(); } // Always update lastCursor to track the latest entry ID, even if text is null diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs index b15f6e8f42..ffd2e39ae2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs @@ -35,7 +35,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi .Build(); private static bool s_infrastructureStarted; - private static readonly TimeSpan s_orchestrationTimeout = TimeSpan.FromMinutes(1); + private static readonly TimeSpan s_orchestrationTimeout = TimeSpan.FromMinutes(2); // In CI, `dotnet run` builds the Functions project from scratch before the host starts, so 60s is not enough. private static readonly TimeSpan s_functionsReadyTimeout = TimeSpan.FromSeconds(180); From b1b528e4a8a1da53f61ad231197ae8b85ed7bb3a Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 27 Mar 2026 22:00:12 +0100 Subject: [PATCH 22/22] Python: [BREAKING] Remove deprecated kwargs compatibility paths (#4858) * [BREAKING] Remove deprecated kwargs compatibility paths Remove the deprecated kwargs compatibility shims across core agents, clients, tools, middleware, and telemetry. Keep workflow kwargs behavior intact in this branch and follow up separately in #4850. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix PR CI fallout for kwargs removal Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * updates * Fix Azure AI CI fallout Remove the stale _get_current_conversation_id override from the Azure AI client after the OpenAI base helper was deleted. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fixed new classes * Fix Assistants deprecated import gating Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix integration replay regressions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Switch multi-agent hosting samples to Azure chat completions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Simplify Azure multi-agent sample config Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workflows/python-integration-tests.yml | 1 + .github/workflows/python-merge-tests.yml | 1 + python/packages/a2a/tests/test_a2a_agent.py | 16 +- .../_agent_provider.py | 5 +- .../agent_framework_azure_ai/_client.py | 5 - .../_deprecated_azure_openai.py | 8 +- .../_project_provider.py | 7 +- .../test_azure_responses_client.py | 8 +- .../claude/agent_framework_claude/_agent.py | 74 +++- .../packages/core/agent_framework/_agents.py | 107 +++--- .../packages/core/agent_framework/_clients.py | 49 +-- python/packages/core/agent_framework/_mcp.py | 3 +- .../core/agent_framework/_middleware.py | 51 ++- .../packages/core/agent_framework/_tools.py | 77 ++-- .../_workflows/_agent_executor.py | 8 +- .../core/agent_framework/observability.py | 123 +++--- .../packages/core/tests/core/test_agents.py | 20 +- .../packages/core/tests/core/test_clients.py | 98 +---- .../core/test_function_invocation_logic.py | 48 ++- .../test_kwargs_propagation_to_ai_function.py | 351 ------------------ python/packages/core/tests/core/test_mcp.py | 24 +- .../tests/core/test_middleware_with_agent.py | 81 ++-- .../tests/core/test_middleware_with_chat.py | 72 ++-- .../core/tests/core/test_observability.py | 16 +- python/packages/core/tests/core/test_tools.py | 35 +- python/packages/devui/tests/devui/conftest.py | 12 +- .../devui/tests/devui/test_execution.py | 2 +- .../agent_framework_durabletask/_entities.py | 19 +- .../tests/test_durable_entities.py | 50 +++ .../foundry/agent_framework_foundry/_agent.py | 140 +++++-- .../agent_framework_foundry/_chat_client.py | 33 +- .../tests/foundry/test_foundry_agent.py | 54 +++ .../tests/foundry/test_foundry_chat_client.py | 21 ++ .../_foundry_local_client.py | 96 ++++- .../tests/test_foundry_local_client.py | 10 + .../tau2/agent_framework_lab_tau2/runner.py | 4 +- .../openai/agent_framework_openai/__init__.py | 2 +- .../_assistant_provider.py | 4 +- .../_assistants_client.py | 12 +- .../agent_framework_openai/_chat_client.py | 97 +++-- .../_chat_completion_client.py | 67 +++- .../_embedding_client.py | 10 +- .../openai/test_openai_assistants_client.py | 27 ++ .../tests/openai/test_openai_chat_client.py | 56 ++- .../openai/test_openai_chat_client_azure.py | 6 +- .../test_openai_chat_completion_client.py | 43 ++- ...test_openai_chat_completion_client_base.py | 4 +- .../openai/test_openai_embedding_client.py | 9 + .../02_multi_agent/function_app.py | 13 +- .../durabletask/02_multi_agent/client.py | 2 +- .../durabletask/02_multi_agent/sample.py | 2 +- .../durabletask/02_multi_agent/worker.py | 24 +- 52 files changed, 1136 insertions(+), 971 deletions(-) delete mode 100644 python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py diff --git a/.github/workflows/python-integration-tests.yml b/.github/workflows/python-integration-tests.yml index 2d12425312..df2fda5cb2 100644 --- a/.github/workflows/python-integration-tests.yml +++ b/.github/workflows/python-integration-tests.yml @@ -210,6 +210,7 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} FUNCTIONS_WORKER_RUNTIME: "python" diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml index f32fceccb5..453e4335a6 100644 --- a/.github/workflows/python-merge-tests.yml +++ b/.github/workflows/python-merge-tests.yml @@ -341,6 +341,7 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} FUNCTIONS_WORKER_RUNTIME: "python" diff --git a/python/packages/a2a/tests/test_a2a_agent.py b/python/packages/a2a/tests/test_a2a_agent.py index 0d81179cd1..58a82f6d8c 100644 --- a/python/packages/a2a/tests/test_a2a_agent.py +++ b/python/packages/a2a/tests/test_a2a_agent.py @@ -366,7 +366,7 @@ def test_get_uri_data_invalid_uri() -> None: def test_parse_contents_from_a2a_conversion(a2a_agent: A2AAgent) -> None: """Test A2A parts to contents conversion.""" - agent = A2AAgent(name="Test Agent", client=MockA2AClient(), _http_client=None) + agent = A2AAgent(name="Test Agent", client=MockA2AClient(), http_client=None) # Create A2A parts parts = [Part(root=TextPart(text="First part")), Part(root=TextPart(text="Second part"))] @@ -485,7 +485,7 @@ async def test_context_manager_no_cleanup_when_no_http_client() -> None: mock_a2a_client = MagicMock() - agent = A2AAgent(client=mock_a2a_client, _http_client=None) + agent = A2AAgent(client=mock_a2a_client, http_client=None) # This should not raise any errors async with agent: @@ -495,7 +495,7 @@ async def test_context_manager_no_cleanup_when_no_http_client() -> None: def test_prepare_message_for_a2a_with_multiple_contents() -> None: """Test conversion of Message with multiple contents.""" - agent = A2AAgent(client=MagicMock(), _http_client=None) + agent = A2AAgent(client=MagicMock(), http_client=None) # Create message with multiple content types message = Message( @@ -523,7 +523,7 @@ def test_prepare_message_for_a2a_with_multiple_contents() -> None: def test_prepare_message_for_a2a_forwards_context_id() -> None: """Test conversion of Message preserves context_id without duplicating it in metadata.""" - agent = A2AAgent(client=MagicMock(), _http_client=None) + agent = A2AAgent(client=MagicMock(), http_client=None) message = Message( role="user", @@ -540,7 +540,7 @@ def test_prepare_message_for_a2a_forwards_context_id() -> None: def test_parse_contents_from_a2a_with_data_part() -> None: """Test conversion of A2A DataPart.""" - agent = A2AAgent(client=MagicMock(), _http_client=None) + agent = A2AAgent(client=MagicMock(), http_client=None) # Create DataPart data_part = Part(root=DataPart(data={"key": "value", "number": 42}, metadata={"source": "test"})) @@ -556,7 +556,7 @@ def test_parse_contents_from_a2a_with_data_part() -> None: def test_parse_contents_from_a2a_unknown_part_kind() -> None: """Test error handling for unknown A2A part kind.""" - agent = A2AAgent(client=MagicMock(), _http_client=None) + agent = A2AAgent(client=MagicMock(), http_client=None) # Create a mock part with unknown kind mock_part = MagicMock() @@ -569,7 +569,7 @@ def test_parse_contents_from_a2a_unknown_part_kind() -> None: def test_prepare_message_for_a2a_with_hosted_file() -> None: """Test conversion of Message with HostedFileContent to A2A message.""" - agent = A2AAgent(client=MagicMock(), _http_client=None) + agent = A2AAgent(client=MagicMock(), http_client=None) # Create message with hosted file content message = Message( @@ -595,7 +595,7 @@ def test_prepare_message_for_a2a_with_hosted_file() -> None: def test_parse_contents_from_a2a_with_hosted_file_uri() -> None: """Test conversion of A2A FilePart with hosted file URI back to UriContent.""" - agent = A2AAgent(client=MagicMock(), _http_client=None) + agent = A2AAgent(client=MagicMock(), http_client=None) # Create FilePart with hosted file URI (simulating what A2A would send back) file_part = Part( diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_agent_provider.py b/python/packages/azure-ai/agent_framework_azure_ai/_agent_provider.py index 51589a0d84..a43702d8b3 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_agent_provider.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_agent_provider.py @@ -445,6 +445,8 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]): # Merge tools: convert agent's hosted tools + user-provided function tools merged_tools = self._merge_tools(agent.tools, provided_tools) + merged_default_options: dict[str, Any] = dict(default_options) if default_options is not None else {} + merged_default_options.setdefault("model_id", agent.model) return Agent( # type: ignore[return-value] client=client, @@ -452,9 +454,8 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]): name=agent.name, description=agent.description, instructions=agent.instructions, - model_id=agent.model, tools=merged_tools, - default_options=default_options, # type: ignore[arg-type] + default_options=cast(Any, merged_default_options), middleware=middleware, context_providers=context_providers, ) diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_client.py index 9f96cf0c3a..f4ceb92b5b 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_client.py @@ -603,11 +603,6 @@ class RawAzureAIClient(RawOpenAIChatClient[AzureAIClientOptionsT], Generic[Azure return transformed - @override - def _get_current_conversation_id(self, options: Mapping[str, Any], **kwargs: Any) -> str | None: - """Get the current conversation ID from chat options or kwargs.""" - return options.get("conversation_id") or kwargs.get("conversation_id") or self.conversation_id - @override def _parse_response_from_openai( self, diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py b/python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py index 21f50e930a..8a3a9833ac 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py @@ -24,7 +24,10 @@ from agent_framework._telemetry import AGENT_FRAMEWORK_USER_AGENT, APP_INFO, pre from agent_framework._tools import FunctionInvocationConfiguration, FunctionInvocationLayer from agent_framework._types import Annotation, Content from agent_framework.observability import ChatTelemetryLayer, EmbeddingTelemetryLayer -from agent_framework_openai._assistants_client import OpenAIAssistantsClient, OpenAIAssistantsOptions +from agent_framework_openai._assistants_client import ( + OpenAIAssistantsClient, # type: ignore[reportDeprecated] + OpenAIAssistantsOptions, +) from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient from agent_framework_openai._chat_completion_client import OpenAIChatCompletionOptions, RawOpenAIChatCompletionClient from agent_framework_openai._embedding_client import OpenAIEmbeddingOptions, RawOpenAIEmbeddingClient @@ -673,7 +676,8 @@ AzureOpenAIAssistantsOptions = OpenAIAssistantsOptions "Use OpenAIAssistantsClient (also deprecated) or migrate to OpenAIChatClient." ) class AzureOpenAIAssistantsClient( - OpenAIAssistantsClient[AzureOpenAIAssistantsOptionsT], Generic[AzureOpenAIAssistantsOptionsT] + OpenAIAssistantsClient[AzureOpenAIAssistantsOptionsT], # type: ignore[reportDeprecated] + Generic[AzureOpenAIAssistantsOptionsT], ): """Deprecated Azure OpenAI Assistants client. Use OpenAIAssistantsClient or migrate to OpenAIChatClient.""" diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py b/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py index b4c948efa3..c0430fd47c 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging import sys from collections.abc import Callable, Mapping, MutableMapping, Sequence -from typing import Any, Generic +from typing import Any, Generic, cast from agent_framework import ( AGENT_FRAMEWORK_USER_AGENT, @@ -398,6 +398,8 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]): # from_azure_ai_tools converts hosted tools (MCP, code interpreter, file search, web search) # but function tools need the actual implementations from provided_tools merged_tools = self._merge_tools(details.definition.tools, provided_tools) + merged_default_options: dict[str, Any] = dict(default_options) if default_options is not None else {} + merged_default_options.setdefault("model_id", details.definition.model) return Agent( # type: ignore[return-value] client=client, @@ -405,9 +407,8 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]): name=details.name, description=details.description, instructions=details.definition.instructions, - model_id=details.definition.model, tools=merged_tools, - default_options=default_options, # type: ignore[arg-type] + default_options=cast(Any, merged_default_options), middleware=middleware, context_providers=context_providers, ) diff --git a/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client.py b/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client.py index 99bd2061b7..da2c346d49 100644 --- a/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client.py +++ b/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client.py @@ -477,7 +477,9 @@ async def test_integration_client_agent_existing_session(): ) as first_agent: # Start a conversation and capture the session session = first_agent.create_session() - first_response = await first_agent.run("My hobby is photography. Remember this.", session=session, store=True) + first_response = await first_agent.run( + "My hobby is photography. Remember this.", session=session, options={"store": True} + ) assert isinstance(first_response, AgentResponse) assert first_response.text is not None @@ -492,7 +494,9 @@ async def test_integration_client_agent_existing_session(): instructions="You are a helpful assistant with good memory.", ) as second_agent: # Reuse the preserved session - second_response = await second_agent.run("What is my hobby?", session=preserved_session) + second_response = await second_agent.run( + "What is my hobby?", session=preserved_session, options={"store": True} + ) assert isinstance(second_response, AgentResponse) assert second_response.text is not None diff --git a/python/packages/claude/agent_framework_claude/_agent.py b/python/packages/claude/agent_framework_claude/_agent.py index 23703b2c53..dd30a3b2d2 100644 --- a/python/packages/claude/agent_framework_claude/_agent.py +++ b/python/packages/claude/agent_framework_claude/_agent.py @@ -7,7 +7,7 @@ import logging import sys from collections.abc import AsyncIterable, Awaitable, Callable, MutableMapping, Sequence from pathlib import Path -from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, overload +from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, cast, overload from agent_framework import ( AgentMiddlewareTypes, @@ -584,7 +584,7 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]): return AgentResponse.from_updates(updates, value=structured_output) @overload - def run( + def run( # type: ignore[override] self, messages: AgentRunInputs | None = None, *, @@ -595,7 +595,7 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]): ) -> Awaitable[AgentResponse[Any]]: ... @overload - def run( + def run( # type: ignore[override] self, messages: AgentRunInputs | None = None, *, @@ -747,3 +747,71 @@ class ClaudeAgent(AgentTelemetryLayer, RawClaudeAgent[OptionsT], Generic[Options response = await agent.run("Hello!") print(response.text) """ + + @overload # type: ignore[override] + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: Literal[False] = ..., + session: AgentSession | None = None, + middleware: Sequence[AgentMiddlewareTypes] | None = None, + options: OptionsT | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, + compaction_strategy: Any = None, + tokenizer: Any = None, + function_invocation_kwargs: dict[str, Any] | None = None, + client_kwargs: dict[str, Any] | None = None, + **kwargs: Any, + ) -> Awaitable[AgentResponse[Any]]: ... + + @overload # type: ignore[override] + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: Literal[True], + session: AgentSession | None = None, + middleware: Sequence[AgentMiddlewareTypes] | None = None, + options: OptionsT | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, + compaction_strategy: Any = None, + tokenizer: Any = None, + function_invocation_kwargs: dict[str, Any] | None = None, + client_kwargs: dict[str, Any] | None = None, + **kwargs: Any, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... + + def run( # pyright: ignore[reportIncompatibleMethodOverride] # type: ignore[override] + self, + messages: AgentRunInputs | None = None, + *, + stream: bool = False, + session: AgentSession | None = None, + middleware: Sequence[AgentMiddlewareTypes] | None = None, + options: OptionsT | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, + compaction_strategy: Any = None, + tokenizer: Any = None, + function_invocation_kwargs: dict[str, Any] | None = None, + client_kwargs: dict[str, Any] | None = None, + **kwargs: Any, + ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: + """Run the Claude agent with telemetry enabled.""" + super_run = cast( + "Callable[..., Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]]", + super().run, + ) + return super_run( + messages=messages, + stream=stream, + session=session, + middleware=middleware, + options=options, + tools=tools, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, + **kwargs, + ) diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py index 27a6a45747..1868742111 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -5,7 +5,6 @@ from __future__ import annotations import logging import re import sys -import warnings from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequence from contextlib import AbstractAsyncContextManager, AsyncExitStack from copy import deepcopy @@ -248,7 +247,6 @@ class SupportsAgentRun(Protocol): session: AgentSession | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[AgentResponse[Any]]: """Get a response from the agent (non-streaming).""" ... @@ -262,7 +260,6 @@ class SupportsAgentRun(Protocol): session: AgentSession | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: """Get a streaming response from the agent.""" ... @@ -275,7 +272,6 @@ class SupportsAgentRun(Protocol): session: AgentSession | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: """Get a response from the agent. @@ -291,7 +287,6 @@ class SupportsAgentRun(Protocol): session: The conversation session associated with the message(s). function_invocation_kwargs: Keyword arguments forwarded to tool invocation. client_kwargs: Additional client-specific keyword arguments. - kwargs: Additional keyword arguments. Returns: When stream=False: An AgentResponse with the final result. @@ -334,7 +329,15 @@ class BaseAgent(SerializationMixin): # Create a concrete subclass that implements the protocol class SimpleAgent(BaseAgent): - async def run(self, messages=None, *, stream=False, session=None, **kwargs): + async def run( + self, + messages=None, + *, + stream=False, + session=None, + function_invocation_kwargs=None, + client_kwargs=None, + ): if stream: async def _stream(): @@ -373,7 +376,6 @@ class BaseAgent(SerializationMixin): context_providers: Sequence[BaseContextProvider] | None = None, middleware: Sequence[MiddlewareTypes] | None = None, additional_properties: MutableMapping[str, Any] | None = None, - **kwargs: Any, ) -> None: """Initialize a BaseAgent instance. @@ -385,15 +387,7 @@ class BaseAgent(SerializationMixin): context_providers: Context providers to include during agent invocation. middleware: List of middleware. additional_properties: Additional properties set on the agent. - kwargs: Additional keyword arguments (merged into additional_properties). """ - if kwargs: - warnings.warn( - "Passing additional properties as direct keyword arguments to BaseAgent is deprecated; " - "pass them via additional_properties instead.", - DeprecationWarning, - stacklevel=3, - ) if id is None: id = str(uuid4()) self.id = id @@ -403,10 +397,7 @@ class BaseAgent(SerializationMixin): self.middleware: list[MiddlewareTypes] | None = ( cast(list[MiddlewareTypes], middleware) if middleware is not None else None ) - - # Merge kwargs into additional_properties self.additional_properties: dict[str, Any] = cast(dict[str, Any], additional_properties or {}) - self.additional_properties.update(kwargs) def create_session(self, *, session_id: str | None = None) -> AgentSession: """Create a new lightweight session. @@ -666,9 +657,10 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, default_options: OptionsCoT | None = None, context_providers: Sequence[BaseContextProvider] | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, - **kwargs: Any, + additional_properties: MutableMapping[str, Any] | None = None, ) -> None: """Initialize a Agent instance. @@ -695,7 +687,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] If both this and a compaction_strategy on the underlying client are set, this one is used. tokenizer: Optional agent-level tokenizer. If both this and a tokenizer on the underlying client are set, this one is used. - kwargs: Any additional keyword arguments. Will be stored as ``additional_properties``. + additional_properties: Additional properties stored on the agent. """ opts = dict(default_options) if default_options else {} @@ -709,7 +701,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] name=name, description=description, context_providers=context_providers, - **kwargs, + middleware=middleware, + additional_properties=additional_properties, ) self.client = client self.compaction_strategy = compaction_strategy @@ -812,7 +805,6 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[AgentResponse[ResponseModelBoundT]]: ... @overload @@ -828,7 +820,6 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[AgentResponse[Any]]: ... @overload @@ -844,7 +835,6 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... def run( @@ -859,7 +849,6 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: """Run the agent with the given messages and options. @@ -890,21 +879,12 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] is used, falling back to the client default. function_invocation_kwargs: Keyword arguments forwarded to tool invocation. client_kwargs: Additional client-specific keyword arguments for the chat client. - kwargs: Deprecated additional keyword arguments for the agent. - They are forwarded to both tool invocation and the chat client for compatibility. Returns: When stream=False: An Awaitable[AgentResponse] containing the agent's response. When stream=True: A ResponseStream of AgentResponseUpdate items with ``get_final_response()`` for the final AgentResponse. """ - if kwargs: - warnings.warn( - "Passing runtime keyword arguments directly to run() is deprecated; pass tool values via " - "function_invocation_kwargs and client-specific values via client_kwargs instead.", - DeprecationWarning, - stacklevel=2, - ) if not stream: async def _run_non_streaming() -> AgentResponse[Any]: @@ -915,7 +895,6 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] options=options, compaction_strategy=compaction_strategy, tokenizer=tokenizer, - legacy_kwargs=kwargs, function_invocation_kwargs=function_invocation_kwargs, client_kwargs=client_kwargs, ) @@ -1003,7 +982,6 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] options=options, compaction_strategy=compaction_strategy, tokenizer=tokenizer, - legacy_kwargs=kwargs, function_invocation_kwargs=function_invocation_kwargs, client_kwargs=client_kwargs, ) @@ -1103,7 +1081,6 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] options: Mapping[str, Any] | None, compaction_strategy: CompactionStrategy | None, tokenizer: TokenizerProtocol | None, - legacy_kwargs: Mapping[str, Any], function_invocation_kwargs: Mapping[str, Any] | None, client_kwargs: Mapping[str, Any] | None, ) -> _RunContext: @@ -1176,12 +1153,9 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] duplicate_error_message=mcp_duplicate_message, ) - # TODO(Copilot): Delete once direct ``run(**kwargs)`` compatibility is removed. - # Legacy compatibility still fans out direct run kwargs into tool runtime kwargs. - effective_function_invocation_kwargs = { - **dict(legacy_kwargs), - **(dict(function_invocation_kwargs) if function_invocation_kwargs is not None else {}), - } + effective_function_invocation_kwargs = ( + dict(function_invocation_kwargs) if function_invocation_kwargs is not None else {} + ) additional_function_arguments = {**effective_function_invocation_kwargs, **existing_additional_args} # Build options dict from run() options merged with provided options @@ -1214,12 +1188,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc] # Build session_messages from session context: context messages + input messages session_messages: list[Message] = session_context.get_messages(include_input=True) - # TODO(Copilot): Delete once direct ``run(**kwargs)`` compatibility is removed. - # Legacy compatibility still fans out direct run kwargs into client kwargs. - effective_client_kwargs = { - **dict(legacy_kwargs), - **(dict(client_kwargs) if client_kwargs is not None else {}), - } + effective_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} if active_session is not None: effective_client_kwargs["session"] = active_session @@ -1499,9 +1468,29 @@ class Agent( *, stream: Literal[False] = ..., session: AgentSession | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, + options: ChatOptions[ResponseModelBoundT], + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + ) -> Awaitable[AgentResponse[ResponseModelBoundT]]: ... + + @overload + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: Literal[False] = ..., + session: AgentSession | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, + options: OptionsCoT | ChatOptions[None] | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[AgentResponse[Any]]: ... @overload @@ -1511,9 +1500,13 @@ class Agent( *, stream: Literal[True], session: AgentSession | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, + options: OptionsCoT | ChatOptions[Any] | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... def run( @@ -1523,10 +1516,12 @@ class Agent( stream: bool = False, session: AgentSession | None = None, middleware: Sequence[MiddlewareTypes] | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, options: OptionsCoT | ChatOptions[Any] | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: """Run the agent.""" super_run = cast( @@ -1538,10 +1533,12 @@ class Agent( stream=stream, session=session, middleware=middleware, + tools=tools, options=options, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, function_invocation_kwargs=function_invocation_kwargs, client_kwargs=client_kwargs, - **kwargs, ) def __init__( @@ -1558,7 +1555,7 @@ class Agent( middleware: Sequence[MiddlewareTypes] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, - **kwargs: Any, + additional_properties: MutableMapping[str, Any] | None = None, ) -> None: """Initialize a Agent instance.""" super().__init__( @@ -1573,7 +1570,7 @@ class Agent( middleware=middleware, compaction_strategy=compaction_strategy, tokenizer=tokenizer, - **kwargs, + additional_properties=additional_properties, ) diff --git a/python/packages/core/agent_framework/_clients.py b/python/packages/core/agent_framework/_clients.py index 66740f5bf8..1865da7928 100644 --- a/python/packages/core/agent_framework/_clients.py +++ b/python/packages/core/agent_framework/_clients.py @@ -4,7 +4,6 @@ from __future__ import annotations import logging import sys -import warnings from abc import ABC, abstractmethod from collections.abc import ( AsyncIterable, @@ -139,7 +138,8 @@ class SupportsChatGetResponse(Protocol[OptionsContraT]): options: ChatOptions[ResponseModelBoundT], compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, - **kwargs: Any, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, ) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ... @overload @@ -153,7 +153,6 @@ class SupportsChatGetResponse(Protocol[OptionsContraT]): tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[ChatResponse[Any]]: ... @overload @@ -167,7 +166,6 @@ class SupportsChatGetResponse(Protocol[OptionsContraT]): tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... def get_response( @@ -180,7 +178,6 @@ class SupportsChatGetResponse(Protocol[OptionsContraT]): tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: """Send input and return the response. @@ -192,7 +189,6 @@ class SupportsChatGetResponse(Protocol[OptionsContraT]): tokenizer: Optional per-call tokenizer override. function_invocation_kwargs: Keyword arguments forwarded only to tool invocation layers. client_kwargs: Additional client-specific keyword arguments. - **kwargs: Deprecated additional client-specific keyword arguments. Returns: When stream=False: An awaitable ChatResponse from the client. @@ -296,7 +292,6 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, additional_properties: dict[str, Any] | None = None, - **kwargs: Any, ) -> None: """Initialize a BaseChatClient instance. @@ -304,19 +299,10 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): compaction_strategy: Optional compaction strategy to apply before model calls. tokenizer: Optional tokenizer used by token-aware compaction strategies. additional_properties: Additional properties for the client. - kwargs: Additional keyword arguments (merged into additional_properties for now). """ self.additional_properties = additional_properties or {} self.compaction_strategy = compaction_strategy self.tokenizer = tokenizer - if kwargs: - warnings.warn( - "Passing additional properties as direct keyword arguments to BaseChatClient is deprecated; " - "pass them via additional_properties instead.", - DeprecationWarning, - stacklevel=3, - ) - self.additional_properties.update(kwargs) super().__init__() def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]: @@ -457,7 +443,8 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): options: ChatOptions[ResponseModelBoundT], compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, - **kwargs: Any, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, ) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ... @overload @@ -469,7 +456,8 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): options: OptionsCoT | ChatOptions[None] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, - **kwargs: Any, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, ) -> Awaitable[ChatResponse[Any]]: ... @overload @@ -481,7 +469,8 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): options: OptionsCoT | ChatOptions[Any] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, - **kwargs: Any, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... def get_response( @@ -492,7 +481,8 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): options: OptionsCoT | ChatOptions[Any] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, - **kwargs: Any, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: """Get a response from a chat client. @@ -504,13 +494,9 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): When omitted, the client-level default is used. tokenizer: Optional per-call tokenizer override. When omitted, the client-level default is used. - **kwargs: Additional compatibility keyword arguments. Lower chat-client layers do not - consume ``function_invocation_kwargs`` directly; if present, it is ignored here - because function invocation has already been handled by upper layers. If a - ``client_kwargs`` mapping is present, it is flattened into standard keyword - arguments before forwarding to ``_inner_get_response()`` so client implementations - can leverage those values, while implementations that ignore - extra kwargs remain compatible. + function_invocation_kwargs: Keyword arguments forwarded only to tool invocation layers. + client_kwargs: Additional client-specific keyword arguments forwarded to + ``_inner_get_response()``. Returns: When streaming a response stream of ChatResponseUpdates, otherwise an Awaitable ChatResponse. @@ -519,14 +505,7 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): compaction_strategy=compaction_strategy, tokenizer=tokenizer, ) - compatibility_client_kwargs = kwargs.pop("client_kwargs", None) - kwargs.pop("function_invocation_kwargs", None) - merged_client_kwargs = ( - dict(cast(Mapping[str, Any], compatibility_client_kwargs)) - if isinstance(compatibility_client_kwargs, Mapping) - else {} - ) - merged_client_kwargs.update(kwargs) + merged_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} if not compaction_overrides: return self._inner_get_response( diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 267e176ee8..0dab38c820 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -768,7 +768,8 @@ class MCPTool: options["stop"] = params.stopSequences try: - response = await self.client.get_response( + chat_client: Any = self.client + response: Any = await chat_client.get_response( messages, options=options or None, ) diff --git a/python/packages/core/agent_framework/_middleware.py b/python/packages/core/agent_framework/_middleware.py index 381482b91a..31950e0d7b 100644 --- a/python/packages/core/agent_framework/_middleware.py +++ b/python/packages/core/agent_framework/_middleware.py @@ -39,7 +39,7 @@ if TYPE_CHECKING: from ._clients import SupportsChatGetResponse from ._compaction import CompactionStrategy, TokenizerProtocol from ._sessions import AgentSession - from ._tools import FunctionTool + from ._tools import FunctionTool, ToolTypes from ._types import ChatOptions, ChatResponse, ChatResponseUpdate ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel) @@ -100,6 +100,7 @@ class AgentContext: agent: The agent being invoked. messages: The messages being sent to the agent. session: The agent session for this invocation, if any. + tools: Run-level tool overrides for this invocation, if any. options: The options for the agent invocation as a dict. stream: Whether this is a streaming invocation. compaction_strategy: Optional per-run compaction override. @@ -142,6 +143,7 @@ class AgentContext: agent: SupportsAgentRun, messages: list[Message], session: AgentSession | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, options: Mapping[str, Any] | None = None, stream: bool = False, compaction_strategy: CompactionStrategy | None = None, @@ -165,6 +167,7 @@ class AgentContext: agent: The agent being invoked. messages: The messages being sent to the agent. session: The agent session for this invocation, if any. + tools: Run-level tool overrides for this invocation, if any. options: The options for the agent invocation as a dict. stream: Whether this is a streaming invocation. compaction_strategy: Optional per-run compaction override. @@ -181,6 +184,7 @@ class AgentContext: self.agent = agent self.messages = messages self.session = session + self.tools = tools self.options = options self.stream = stream self.compaction_strategy = compaction_strategy @@ -1025,7 +1029,7 @@ class ChatMiddlewareLayer(Generic[OptionsCoT]): compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, + client_kwargs: Mapping[str, Any] | None = None, ) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ... @overload @@ -1039,7 +1043,6 @@ class ChatMiddlewareLayer(Generic[OptionsCoT]): tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[ChatResponse[Any]]: ... @overload @@ -1053,7 +1056,6 @@ class ChatMiddlewareLayer(Generic[OptionsCoT]): tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... def get_response( @@ -1066,27 +1068,26 @@ class ChatMiddlewareLayer(Generic[OptionsCoT]): tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: """Execute the chat pipeline if middleware is configured.""" super_get_response = super().get_response # type: ignore[misc] - - if compaction_strategy is not None: - kwargs["compaction_strategy"] = compaction_strategy - if tokenizer is not None: - kwargs["tokenizer"] = tokenizer - effective_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} call_middleware = effective_client_kwargs.pop("middleware", []) + context_kwargs = dict(effective_client_kwargs) + if compaction_strategy is not None: + context_kwargs["compaction_strategy"] = compaction_strategy + if tokenizer is not None: + context_kwargs["tokenizer"] = tokenizer pipeline = self._get_chat_middleware_pipeline(call_middleware) # type: ignore[reportUnknownArgumentType] if not pipeline.has_middlewares: return super_get_response( # type: ignore[no-any-return] messages=messages, stream=stream, options=options, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, function_invocation_kwargs=function_invocation_kwargs, client_kwargs=effective_client_kwargs, - **kwargs, ) context = ChatContext( @@ -1094,7 +1095,7 @@ class ChatMiddlewareLayer(Generic[OptionsCoT]): messages=list(messages), options=options, stream=stream, - kwargs={**effective_client_kwargs, **kwargs}, + kwargs=context_kwargs, function_invocation_kwargs=function_invocation_kwargs, ) @@ -1180,12 +1181,12 @@ class AgentMiddlewareLayer: stream: Literal[False] = ..., session: AgentSession | None = None, middleware: Sequence[MiddlewareTypes] | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, options: ChatOptions[ResponseModelBoundT], compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[AgentResponse[ResponseModelBoundT]]: ... @overload @@ -1196,12 +1197,12 @@ class AgentMiddlewareLayer: stream: Literal[False] = ..., session: AgentSession | None = None, middleware: Sequence[MiddlewareTypes] | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, options: ChatOptions[None] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[AgentResponse[Any]]: ... @overload @@ -1212,12 +1213,12 @@ class AgentMiddlewareLayer: stream: Literal[True], session: AgentSession | None = None, middleware: Sequence[MiddlewareTypes] | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, options: ChatOptions[Any] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... def run( @@ -1227,12 +1228,12 @@ class AgentMiddlewareLayer: stream: bool = False, session: AgentSession | None = None, middleware: Sequence[MiddlewareTypes] | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, options: ChatOptions[Any] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: """MiddlewareTypes-enabled unified run method.""" # Re-categorize self.middleware at runtime to support dynamic changes @@ -1263,23 +1264,23 @@ class AgentMiddlewareLayer: messages, stream=stream, session=session, + tools=tools, options=options, compaction_strategy=compaction_strategy, tokenizer=tokenizer, function_invocation_kwargs=effective_function_invocation_kwargs, client_kwargs=effective_client_kwargs, - **kwargs, ) context = AgentContext( agent=self, # type: ignore[arg-type] messages=normalize_messages(messages), session=session, + tools=tools, options=options, stream=stream, compaction_strategy=compaction_strategy, tokenizer=tokenizer, - kwargs=kwargs, client_kwargs=effective_client_kwargs, function_invocation_kwargs=effective_function_invocation_kwargs, ) @@ -1313,22 +1314,16 @@ class AgentMiddlewareLayer: def _middleware_handler( self, context: AgentContext ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]: - # TODO(Copilot): Delete once direct ``run(**kwargs)`` compatibility is removed. - client_kwargs = {**context.client_kwargs, **context.kwargs} - # TODO(Copilot): Delete once direct ``run(**kwargs)`` compatibility is removed. - function_invocation_kwargs = { - **context.function_invocation_kwargs, - **{k: v for k, v in context.kwargs.items() if k != "middleware"}, - } return super().run( # type: ignore[misc, no-any-return] context.messages, stream=context.stream, session=context.session, + tools=context.tools, options=context.options, compaction_strategy=context.compaction_strategy, tokenizer=context.tokenizer, - function_invocation_kwargs=function_invocation_kwargs, - client_kwargs=client_kwargs, + function_invocation_kwargs=context.function_invocation_kwargs, + client_kwargs=context.client_kwargs, ) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index f7bc3f0e15..521a0c4d96 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -8,7 +8,6 @@ import json import logging import sys import typing -import warnings from collections.abc import ( AsyncIterable, Awaitable, @@ -344,8 +343,6 @@ class FunctionTool(SerializationMixin): self._instance = None # Store the instance for bound methods self._context_parameter_name: str | None = None self._input_model_explicitly_provided = input_model is not None - # TODO(Copilot): Delete once legacy ``**kwargs`` runtime injection is removed. - self._forward_runtime_kwargs: bool = False if self.func: self._discover_injected_parameters() @@ -390,10 +387,6 @@ class FunctionTool(SerializationMixin): for name, param in signature.parameters.items(): if name in {"self", "cls"}: continue - if param.kind == inspect.Parameter.VAR_KEYWORD: - self._forward_runtime_kwargs = True - continue - annotation = type_hints.get(name, param.annotation) if self._is_context_parameter(name, annotation): if self._context_parameter_name is not None: @@ -518,6 +511,7 @@ class FunctionTool(SerializationMixin): *, arguments: BaseModel | Mapping[str, Any] | None = None, context: FunctionInvocationContext | None = None, + tool_call_id: str | None = None, **kwargs: Any, ) -> list[Content]: """Run the AI function with the provided arguments as a Pydantic model. @@ -530,7 +524,10 @@ class FunctionTool(SerializationMixin): Keyword Args: arguments: A mapping or model instance containing the arguments for the function. context: Explicit function invocation context carrying runtime kwargs. - kwargs: Deprecated keyword arguments to pass to the function. Use ``context`` instead. + tool_call_id: Optional tool call identifier used for telemetry and tracing. + kwargs: Direct function argument values. When provided, every keyword + must match a declared tool parameter. Runtime data must be passed + via ``context``. Returns: A list of Content items representing the tool output. @@ -552,18 +549,13 @@ class FunctionTool(SerializationMixin): {key: value for key, value in kwargs.items() if key in parameter_names} if arguments is None else {} ) runtime_kwargs = dict(context.kwargs) if context is not None else {} - deprecated_runtime_kwargs = { - key: value for key, value in kwargs.items() if key not in direct_argument_kwargs and key != "tool_call_id" - } - if deprecated_runtime_kwargs: - warnings.warn( - "Passing runtime keyword arguments directly to FunctionTool.invoke() is deprecated; " - "pass them via FunctionInvocationContext instead.", - DeprecationWarning, - stacklevel=2, + unexpected_kwargs = {key: value for key, value in kwargs.items() if key not in direct_argument_kwargs} + if unexpected_kwargs: + unexpected_names = ", ".join(sorted(unexpected_kwargs)) + raise TypeError( + f"Unexpected keyword argument(s) for tool '{self.name}': {unexpected_names}. " + "Pass runtime data via FunctionInvocationContext instead." ) - runtime_kwargs.update(deprecated_runtime_kwargs) - tool_call_id = kwargs.get("tool_call_id", runtime_kwargs.pop("tool_call_id", None)) if arguments is None and direct_argument_kwargs: arguments = direct_argument_kwargs if arguments is None and context is not None: @@ -614,17 +606,6 @@ class FunctionTool(SerializationMixin): call_kwargs = dict(validated_arguments) observable_kwargs = dict(validated_arguments) - - # Legacy runtime kwargs injection path retained for backwards compatibility with tools - # that still declare ``**kwargs``. New tools should consume runtime data via ``ctx``. - legacy_runtime_kwargs = dict(runtime_kwargs) - if self._forward_runtime_kwargs and legacy_runtime_kwargs: - for key, value in legacy_runtime_kwargs.items(): - if key not in call_kwargs: - call_kwargs[key] = value - if key not in observable_kwargs: - observable_kwargs[key] = value - if self._context_parameter_name is not None and effective_context is not None: call_kwargs[self._context_parameter_name] = effective_context @@ -1420,7 +1401,7 @@ async def _auto_invoke_function( # No middleware - execute directly try: direct_context = None - if getattr(tool, "_forward_runtime_kwargs", False) or getattr(tool, "_context_parameter_name", None): + if getattr(tool, "_context_parameter_name", None): direct_context = FunctionInvocationContext( function=tool, arguments=args, @@ -2078,7 +2059,6 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ... @overload @@ -2093,7 +2073,6 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[ChatResponse[Any]]: ... @overload @@ -2108,7 +2087,6 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... def get_response( @@ -2122,7 +2100,6 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: from ._middleware import categorize_middleware from ._types import ( @@ -2133,14 +2110,6 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): ) super_get_response = super().get_response # type: ignore[misc] - if kwargs: - warnings.warn( - "Passing client-specific keyword arguments directly to get_response() is deprecated; " - "pass them via client_kwargs instead.", - DeprecationWarning, - stacklevel=2, - ) - effective_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} if middleware is not None: existing = effective_client_kwargs.get("middleware", []) @@ -2176,19 +2145,23 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): invocation_session=invocation_session, middleware_pipeline=function_middleware_pipeline, ) - filtered_kwargs = {k: v for k, v in {**effective_client_kwargs, **kwargs}.items() if k != "session"} + filtered_kwargs = {k: v for k, v in effective_client_kwargs.items() if k != "session"} # Make options mutable so we can update conversation_id during function invocation loop mutable_options: dict[str, Any] = dict(options) if options else {} # Remove additional_function_arguments from options passed to underlying chat client # It's for tool invocation only and not recognized by chat service APIs mutable_options.pop("additional_function_arguments", None) - # Support tools passed via kwargs in direct client.get_response(...) calls. - if "tools" in filtered_kwargs: - if mutable_options.get("tools") is None: - mutable_options["tools"] = filtered_kwargs["tools"] - filtered_kwargs.pop("tools", None) - + if not self.function_invocation_configuration.get("enabled", True): + return super_get_response( # type: ignore[no-any-return] + messages=messages, + stream=stream, + options=mutable_options, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=filtered_kwargs, + ) if not stream: async def _get_response() -> ChatResponse[Any]: @@ -2235,7 +2208,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): aggregated_usage = add_usage_details(aggregated_usage, response.usage_details) if response.conversation_id is not None: - _update_conversation_id(kwargs, response.conversation_id, mutable_options) + _update_conversation_id(filtered_kwargs, response.conversation_id, mutable_options) prepped_messages = [] result = await _process_function_requests( @@ -2379,7 +2352,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): return if response.conversation_id is not None: - _update_conversation_id(kwargs, response.conversation_id, mutable_options) + _update_conversation_id(filtered_kwargs, response.conversation_id, mutable_options) prepped_messages = [] result = await _process_function_requests( diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py index 462c3f8c64..02b4da943c 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_executor.py +++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py @@ -12,7 +12,7 @@ from agent_framework import Content from .._agents import SupportsAgentRun from .._sessions import AgentSession -from .._types import AgentResponse, AgentResponseUpdate, Message +from .._types import AgentResponse, AgentResponseUpdate, Message, ResponseStream from ._agent_utils import resolve_agent_id from ._const import WORKFLOW_RUN_KWARGS_KEY from ._executor import Executor, handler @@ -352,7 +352,8 @@ class AgentExecutor(Executor): """ run_kwargs, options = self._prepare_agent_run_args(ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {})) - response = await self._agent.run( + run_agent = cast(Callable[..., Awaitable[AgentResponse[Any]]], self._agent.run) + response = await run_agent( self._cache, stream=False, session=self._session, @@ -383,7 +384,8 @@ class AgentExecutor(Executor): updates: list[AgentResponseUpdate] = [] streamed_user_input_requests: list[Content] = [] - stream = self._agent.run( + run_agent_stream = cast(Callable[..., ResponseStream[AgentResponseUpdate, AgentResponse[Any]]], self._agent.run) + stream = run_agent_stream( self._cache, stream=True, session=self._session, diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index f673f5cc61..236daa29a0 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -49,8 +49,9 @@ if TYPE_CHECKING: # pragma: no cover from ._agents import SupportsAgentRun from ._clients import SupportsChatGetResponse from ._compaction import CompactionStrategy, TokenizerProtocol + from ._middleware import MiddlewareTypes from ._sessions import AgentSession - from ._tools import FunctionTool + from ._tools import FunctionTool, ToolTypes from ._types import ( AgentResponse, AgentResponseUpdate, @@ -1191,7 +1192,8 @@ class ChatTelemetryLayer(Generic[OptionsCoT]): options: ChatOptions[ResponseModelBoundT], compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, - **kwargs: Any, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, ) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ... @overload @@ -1203,7 +1205,8 @@ class ChatTelemetryLayer(Generic[OptionsCoT]): options: OptionsCoT | ChatOptions[None] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, - **kwargs: Any, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, ) -> Awaitable[ChatResponse[Any]]: ... @overload @@ -1215,7 +1218,8 @@ class ChatTelemetryLayer(Generic[OptionsCoT]): options: OptionsCoT | ChatOptions[Any] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, - **kwargs: Any, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... def get_response( @@ -1226,7 +1230,8 @@ class ChatTelemetryLayer(Generic[OptionsCoT]): options: OptionsCoT | ChatOptions[Any] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, - **kwargs: Any, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: """Trace chat responses with OpenTelemetry spans and metrics. @@ -1238,25 +1243,14 @@ class ChatTelemetryLayer(Generic[OptionsCoT]): tokenizer: Optional tokenizer used by token-aware compaction strategies. Keyword Args: - kwargs: Compatibility keyword arguments from higher client layers. This layer does - not consume ``function_invocation_kwargs`` directly; if present, it is ignored - because function invocation has already been processed above. If a ``client_kwargs`` - mapping is present, it is flattened into ordinary keyword arguments for tracing and - forwarding so clients that use those values continue to work while clients that - ignore extra kwargs remain compatible. + function_invocation_kwargs: Keyword arguments forwarded only to tool invocation layers. + client_kwargs: Additional client-specific keyword arguments for downstream chat clients. """ from ._types import ChatResponse, ChatResponseUpdate, ResponseStream # type: ignore[reportUnusedImport] global OBSERVABILITY_SETTINGS super_get_response = super().get_response # type: ignore[misc] - compatibility_client_kwargs = kwargs.pop("client_kwargs", None) - kwargs.pop("function_invocation_kwargs", None) - merged_client_kwargs = ( - dict(cast(Mapping[str, Any], compatibility_client_kwargs)) - if isinstance(compatibility_client_kwargs, Mapping) - else {} - ) - merged_client_kwargs.update(kwargs) + merged_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} if not OBSERVABILITY_SETTINGS.ENABLED: return super_get_response( # type: ignore[no-any-return] @@ -1265,7 +1259,8 @@ class ChatTelemetryLayer(Generic[OptionsCoT]): options=options, compaction_strategy=compaction_strategy, tokenizer=tokenizer, - **merged_client_kwargs, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=merged_client_kwargs, ) opts: dict[str, Any] = options or {} # type: ignore[assignment] @@ -1292,7 +1287,8 @@ class ChatTelemetryLayer(Generic[OptionsCoT]): options=opts, compaction_strategy=compaction_strategy, tokenizer=tokenizer, - **merged_client_kwargs, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=merged_client_kwargs, ), ) @@ -1384,7 +1380,8 @@ class ChatTelemetryLayer(Generic[OptionsCoT]): options=opts, compaction_strategy=compaction_strategy, tokenizer=tokenizer, - **merged_client_kwargs, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=merged_client_kwargs, ), ) except Exception as exception: @@ -1512,11 +1509,29 @@ class AgentTelemetryLayer: *, stream: Literal[False] = ..., session: AgentSession | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, + options: ChatOptions[ResponseModelBoundT], + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + ) -> Awaitable[AgentResponse[ResponseModelBoundT]]: ... + + @overload + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: Literal[False] = ..., + session: AgentSession | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, + options: ChatOptions[None] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[AgentResponse[Any]]: ... @overload @@ -1526,11 +1541,13 @@ class AgentTelemetryLayer: *, stream: Literal[True], session: AgentSession | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, + options: ChatOptions[Any] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... def run( @@ -1539,11 +1556,13 @@ class AgentTelemetryLayer: *, stream: bool = False, session: AgentSession | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, + options: ChatOptions[Any] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, - **kwargs: Any, ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: """Trace agent runs with OpenTelemetry spans and metrics.""" global OBSERVABILITY_SETTINGS @@ -1554,23 +1573,27 @@ class AgentTelemetryLayer: super().run, # type: ignore[misc] ) provider_name = str(self.otel_provider_name) + super_run_kwargs: dict[str, Any] = { + "messages": messages, + "stream": stream, + "session": session, + "tools": tools, + "options": options, + "compaction_strategy": compaction_strategy, + "tokenizer": tokenizer, + "function_invocation_kwargs": function_invocation_kwargs, + "client_kwargs": client_kwargs, + } + if middleware is not None: + super_run_kwargs["middleware"] = middleware if not OBSERVABILITY_SETTINGS.ENABLED: - return super_run( # type: ignore[no-any-return] - messages=messages, - stream=stream, - session=session, - compaction_strategy=compaction_strategy, - tokenizer=tokenizer, - function_invocation_kwargs=function_invocation_kwargs, - client_kwargs=client_kwargs, - **kwargs, - ) + return super_run(**super_run_kwargs) # type: ignore[no-any-return] - default_options = getattr(self, "default_options", {}) - options = kwargs.get("options") + default_options = dict(getattr(self, "default_options", {})) merged_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} - merged_client_kwargs.update(kwargs) - merged_options: dict[str, Any] = merge_chat_options(default_options, options or {}) + merged_options: dict[str, Any] = merge_chat_options( + default_options, dict(options) if options is not None else {} + ) attributes = _get_span_attributes( operation_name=OtelAttr.AGENT_INVOKE_OPERATION, provider_name=provider_name, @@ -1590,16 +1613,7 @@ class AgentTelemetryLayer: if stream: try: - run_result: object = super_run( - messages=messages, - stream=True, - session=session, - compaction_strategy=compaction_strategy, - tokenizer=tokenizer, - function_invocation_kwargs=function_invocation_kwargs, - client_kwargs=client_kwargs, - **kwargs, - ) + run_result: object = super_run(**super_run_kwargs) if isinstance(run_result, ResponseStream): result_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]] = run_result # pyright: ignore[reportUnknownVariableType] elif isinstance(run_result, Awaitable): @@ -1693,16 +1707,7 @@ class AgentTelemetryLayer: ) start_time_stamp = perf_counter() try: - response: AgentResponse[Any] = await super_run( - messages=messages, - stream=False, - session=session, - compaction_strategy=compaction_strategy, - tokenizer=tokenizer, - function_invocation_kwargs=function_invocation_kwargs, - client_kwargs=client_kwargs, - **kwargs, - ) + response: AgentResponse[Any] = await super_run(**super_run_kwargs) except Exception as exception: capture_exception(span=span, exception=exception, timestamp=time_ns()) raise diff --git a/python/packages/core/tests/core/test_agents.py b/python/packages/core/tests/core/test_agents.py index c751265fbe..94253b3c34 100644 --- a/python/packages/core/tests/core/test_agents.py +++ b/python/packages/core/tests/core/test_agents.py @@ -148,11 +148,9 @@ async def test_chat_client_agent_init_with_name( assert agent.description == "Test" -def test_agent_init_warns_for_direct_additional_properties(client: SupportsChatGetResponse) -> None: - with pytest.warns(DeprecationWarning, match="additional_properties"): - agent = Agent(client=client, legacy_key="legacy-value") - - assert agent.additional_properties["legacy_key"] == "legacy-value" +def test_agent_init_rejects_direct_additional_properties(client: SupportsChatGetResponse) -> None: + with pytest.raises(TypeError): + Agent(client=client, legacy_key="legacy-value") async def test_chat_client_agent_run(client: SupportsChatGetResponse) -> None: @@ -303,7 +301,6 @@ async def test_prepare_run_context_handles_function_kwargs( }, compaction_strategy=None, tokenizer=None, - legacy_kwargs={"legacy_key": "legacy-value"}, function_invocation_kwargs={"runtime_key": "runtime-value"}, client_kwargs={"client_key": "client-value"}, ) @@ -311,7 +308,6 @@ async def test_prepare_run_context_handles_function_kwargs( assert ctx["chat_options"]["temperature"] == 0.4 assert "additional_function_arguments" not in ctx["chat_options"] assert ctx["function_invocation_kwargs"]["from_options"] == "options-value" - assert ctx["function_invocation_kwargs"]["legacy_key"] == "legacy-value" assert ctx["function_invocation_kwargs"]["runtime_key"] == "runtime-value" assert "session" not in ctx["function_invocation_kwargs"] assert ctx["client_kwargs"]["client_key"] == "client-value" @@ -1181,8 +1177,8 @@ async def test_agent_run_accepts_prefixed_mcp_tools(chat_client_base: Any) -> No assert tool_names == ["search", "docs_search"] -async def test_agent_tool_receives_session_in_kwargs(chat_client_base: Any) -> None: - """Verify legacy **kwargs tools receive the session when agent.run() is called with one.""" +async def test_agent_tool_without_context_does_not_receive_session(chat_client_base: Any) -> None: + """Verify tools without FunctionInvocationContext no longer receive injected session kwargs.""" captured: dict[str, Any] = {} @@ -1215,8 +1211,8 @@ async def test_agent_tool_receives_session_in_kwargs(chat_client_base: Any) -> N result = await agent.run("hello", session=session) assert result.text == "done" - assert captured.get("has_session") is True - assert captured.get("has_state") is True + assert captured.get("has_session") is False + assert captured.get("has_state") is False async def test_agent_tool_receives_explicit_session_via_function_invocation_context_kwargs( @@ -1278,7 +1274,7 @@ async def test_chat_agent_tool_choice_run_level_overrides_agent_level(chat_clien agent = Agent( client=chat_client_base, tools=[tool_tool], - options={"tool_choice": "auto"}, + default_options={"tool_choice": "auto"}, ) # Run with run-level tool_choice="required" diff --git a/python/packages/core/tests/core/test_clients.py b/python/packages/core/tests/core/test_clients.py index 73526298df..9a7e90f5ee 100644 --- a/python/packages/core/tests/core/test_clients.py +++ b/python/packages/core/tests/core/test_clients.py @@ -1,7 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. -import inspect from typing import Any from unittest.mock import patch @@ -15,11 +14,6 @@ from agent_framework import ( Message, SlidingWindowStrategy, SupportsChatGetResponse, - SupportsCodeInterpreterTool, - SupportsFileSearchTool, - SupportsImageGenerationTool, - SupportsMCPTool, - SupportsWebSearchTool, TruncationStrategy, ) @@ -53,11 +47,9 @@ def test_base_client(chat_client_base: SupportsChatGetResponse): assert isinstance(chat_client_base, SupportsChatGetResponse) -def test_base_client_warns_for_direct_additional_properties(chat_client_base: SupportsChatGetResponse) -> None: - with pytest.warns(DeprecationWarning, match="additional_properties"): - client = type(chat_client_base)(legacy_key="legacy-value") - - assert client.additional_properties["legacy_key"] == "legacy-value" +def test_base_client_rejects_direct_additional_properties(chat_client_base: SupportsChatGetResponse) -> None: + with pytest.raises(TypeError): + type(chat_client_base)(legacy_key="legacy-value") def test_base_client_as_agent_uses_explicit_additional_properties(chat_client_base: SupportsChatGetResponse) -> None: @@ -66,27 +58,6 @@ def test_base_client_as_agent_uses_explicit_additional_properties(chat_client_ba assert agent.additional_properties == {"team": "core"} -def test_openai_chat_completion_client_get_response_docstring_surfaces_layered_runtime_docs() -> None: - from agent_framework.openai import OpenAIChatCompletionClient - - docstring = inspect.getdoc(OpenAIChatCompletionClient.get_response) - - assert docstring is not None - assert "Get a response from a chat client." in docstring - assert "function_invocation_kwargs" in docstring - assert "middleware: Optional per-call chat and function middleware." in docstring - assert "function_middleware: Optional per-call function middleware." not in docstring - - -def test_openai_chat_completion_client_get_response_is_defined_on_openai_class() -> None: - from agent_framework.openai import OpenAIChatCompletionClient - - signature = inspect.signature(OpenAIChatCompletionClient.get_response) - - assert OpenAIChatCompletionClient.get_response.__qualname__ == "OpenAIChatCompletionClient.get_response" - assert "middleware" in signature.parameters - - async def test_base_client_get_response_uses_explicit_client_kwargs(chat_client_base: SupportsChatGetResponse) -> None: async def fake_inner_get_response(**kwargs): assert kwargs["trace_id"] == "trace-123" @@ -333,66 +304,3 @@ async def test_chat_client_instructions_handling(chat_client_base: SupportsChatG assert appended_messages[0].text == "You are a helpful assistant." assert appended_messages[1].role == "user" assert appended_messages[1].text == "hello" - - -# region Tool Support Protocol Tests - - -def test_openai_responses_client_supports_all_tool_protocols(): - """Test that OpenAIResponsesClient supports all hosted tool protocols.""" - from agent_framework.openai import OpenAIResponsesClient - - assert isinstance(OpenAIResponsesClient, SupportsCodeInterpreterTool) - assert isinstance(OpenAIResponsesClient, SupportsWebSearchTool) - assert isinstance(OpenAIResponsesClient, SupportsImageGenerationTool) - assert isinstance(OpenAIResponsesClient, SupportsMCPTool) - assert isinstance(OpenAIResponsesClient, SupportsFileSearchTool) - - -def test_openai_chat_completion_client_supports_web_search_only(): - """Test that OpenAIChatClient only supports web search tool.""" - from agent_framework.openai import OpenAIChatCompletionClient - - assert not isinstance(OpenAIChatCompletionClient, SupportsCodeInterpreterTool) - assert isinstance(OpenAIChatCompletionClient, SupportsWebSearchTool) - assert not isinstance(OpenAIChatCompletionClient, SupportsImageGenerationTool) - assert not isinstance(OpenAIChatCompletionClient, SupportsMCPTool) - assert not isinstance(OpenAIChatCompletionClient, SupportsFileSearchTool) - - -def test_openai_assistants_client_supports_code_interpreter_and_file_search(): - """Test that OpenAIAssistantsClient supports code interpreter and file search.""" - from agent_framework.openai import OpenAIAssistantsClient - - assert isinstance(OpenAIAssistantsClient, SupportsCodeInterpreterTool) - assert not isinstance(OpenAIAssistantsClient, SupportsWebSearchTool) - assert not isinstance(OpenAIAssistantsClient, SupportsImageGenerationTool) - assert not isinstance(OpenAIAssistantsClient, SupportsMCPTool) - assert isinstance(OpenAIAssistantsClient, SupportsFileSearchTool) - - -def test_protocol_isinstance_with_client_instance(): - """Test that protocol isinstance works with client instances.""" - from agent_framework.openai import OpenAIResponsesClient - - # Create mock client instance (won't connect to API) - client = OpenAIResponsesClient.__new__(OpenAIResponsesClient) - - assert isinstance(client, SupportsCodeInterpreterTool) - assert isinstance(client, SupportsWebSearchTool) - - -def test_protocol_tool_methods_return_dict(): - """Test that static tool methods return dict[str, Any].""" - from agent_framework.openai import OpenAIResponsesClient - - code_tool = OpenAIResponsesClient.get_code_interpreter_tool() - assert isinstance(code_tool, dict) - assert code_tool.get("type") == "code_interpreter" - - web_tool = OpenAIResponsesClient.get_web_search_tool() - assert isinstance(web_tool, dict) - assert web_tool.get("type") == "web_search" - - -# endregion diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index d9659837a8..96bec7d547 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -13,6 +13,7 @@ from agent_framework import ( Content, Message, SupportsChatGetResponse, + chat_middleware, tool, ) from agent_framework._compaction import ( @@ -74,7 +75,7 @@ async def test_base_client_with_function_calling(chat_client_base: SupportsChatG assert response.messages[2].text == "done" -async def test_base_client_with_function_calling_tools_in_kwargs(chat_client_base: SupportsChatGetResponse): +async def test_base_client_with_function_calling_string_input(chat_client_base: SupportsChatGetResponse): exec_counter = 0 @tool(name="test_function", approval_mode="never_require") @@ -95,7 +96,7 @@ async def test_base_client_with_function_calling_tools_in_kwargs(chat_client_bas ChatResponse(messages=Message(role="assistant", text="done")), ] - response = await chat_client_base.get_response("hello", tools=[ai_func]) + response = await chat_client_base.get_response("hello", options={"tool_choice": "auto", "tools": [ai_func]}) assert exec_counter == 1 assert len(response.messages) == 3 @@ -1429,6 +1430,36 @@ async def test_function_invocation_config_enabled_false(chat_client_base: Suppor assert len(response.messages) > 0 +async def test_function_invocation_config_enabled_false_preserves_invocation_kwargs( + chat_client_base: SupportsChatGetResponse, +): + """Test disabled function invocation still forwards invocation kwargs downstream.""" + captured_kwargs: dict[str, Any] = {} + + @tool(name="test_function") + def ai_func(arg1: str) -> str: + return f"Processed {arg1}" + + @chat_middleware + async def capture_middleware(context, call_next): + captured_kwargs.update(context.function_invocation_kwargs or {}) + await call_next() + + chat_client_base.chat_middleware = [capture_middleware] + chat_client_base.run_responses = [ + ChatResponse(messages=Message(role="assistant", text="response without function calling")), + ] + chat_client_base.function_invocation_configuration["enabled"] = False + + await chat_client_base.get_response( + [Message(role="user", text="hello")], + options={"tool_choice": "auto", "tools": [ai_func]}, + function_invocation_kwargs={"tool_request_id": "tool-123"}, + ) + + assert captured_kwargs == {"tool_request_id": "tool-123"} + + @pytest.mark.skip(reason="Error handling and failsafe behavior needs investigation in unified API") async def test_function_invocation_config_max_consecutive_errors(chat_client_base: SupportsChatGetResponse): """Test that max_consecutive_errors_per_request limits error retries.""" @@ -1523,7 +1554,7 @@ async def test_function_invocation_stop_clears_conversation_id_non_stream(chat_c response = await chat_client_base.get_response( [Message(role="user", text="hello")], options={"tool_choice": "auto", "tools": [error_func]}, - session=session_stub, + client_kwargs={"session": session_stub}, ) assert response.conversation_id is None @@ -1881,8 +1912,7 @@ async def test_hosted_tool_approval_response(chat_client_base: SupportsChatGetRe # Send the approval response response = await chat_client_base.get_response( [Message(role="user", contents=[approval_response])], - tool_choice="auto", - tools=[local_func], + options={"tool_choice": "auto", "tools": [local_func]}, ) # The hosted tool approval should be returned as-is (not executed) @@ -1930,8 +1960,7 @@ async def test_hosted_mcp_approval_response_passthrough(chat_client_base: Suppor response = await chat_client_base.get_response( messages, - tool_choice="auto", - tools=[local_func], + options={"tool_choice": "auto", "tools": [local_func]}, ) # The response should succeed without errors @@ -2024,8 +2053,7 @@ async def test_mixed_local_and_hosted_approval_flow(chat_client_base: SupportsCh response = await chat_client_base.get_response( messages, - tool_choice="auto", - tools=[local_func], + options={"tool_choice": "auto", "tools": [local_func]}, ) assert response is not None @@ -2799,7 +2827,7 @@ async def test_streaming_function_invocation_stop_clears_conversation_id(chat_cl "hello", options={"tool_choice": "auto", "tools": [error_func]}, stream=True, - session=session_stub, + client_kwargs={"session": session_stub}, ) async for _ in stream: pass diff --git a/python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py b/python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py deleted file mode 100644 index 11a738a0b9..0000000000 --- a/python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py +++ /dev/null @@ -1,351 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Tests for kwargs propagation from get_response() to @tool functions.""" - -from collections.abc import AsyncIterable, Awaitable, MutableSequence, Sequence -from typing import Any - -from agent_framework import ( - Agent, - BaseChatClient, - ChatMiddlewareLayer, - ChatResponse, - ChatResponseUpdate, - Content, - FunctionInvocationContext, - FunctionInvocationLayer, - Message, - ResponseStream, - tool, -) -from agent_framework.observability import ChatTelemetryLayer - - -class _MockBaseChatClient(BaseChatClient[Any]): - """Mock chat client for testing function invocation.""" - - def __init__(self) -> None: - super().__init__() - self.run_responses: list[ChatResponse] = [] - self.streaming_responses: list[list[ChatResponseUpdate]] = [] - self.call_count: int = 0 - - def _inner_get_response( - self, - *, - messages: MutableSequence[Message], - stream: bool, - options: dict[str, Any], - **kwargs: Any, - ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: - if stream: - return self._get_streaming_response(messages=messages, options=options, **kwargs) - - async def _get() -> ChatResponse: - return await self._get_non_streaming_response(messages=messages, options=options, **kwargs) - - return _get() - - async def _get_non_streaming_response( - self, - *, - messages: MutableSequence[Message], - options: dict[str, Any], - **kwargs: Any, - ) -> ChatResponse: - self.call_count += 1 - if self.run_responses: - return self.run_responses.pop(0) - return ChatResponse(messages=Message(role="assistant", text="default response")) - - def _get_streaming_response( - self, - *, - messages: MutableSequence[Message], - options: dict[str, Any], - **kwargs: Any, - ) -> ResponseStream[ChatResponseUpdate, ChatResponse]: - async def _stream() -> AsyncIterable[ChatResponseUpdate]: - self.call_count += 1 - if self.streaming_responses: - for update in self.streaming_responses.pop(0): - yield update - else: - yield ChatResponseUpdate( - contents=[Content.from_text("default streaming response")], role="assistant", finish_reason="stop" - ) - - def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse: - response_format = options.get("response_format") - output_format_type = response_format if isinstance(response_format, type) else None - return ChatResponse.from_updates(updates, output_format_type=output_format_type) - - return ResponseStream(_stream(), finalizer=_finalize) - - -class FunctionInvokingMockClient( - FunctionInvocationLayer[Any], - ChatMiddlewareLayer[Any], - ChatTelemetryLayer[Any], - _MockBaseChatClient, -): - """Mock client with function invocation support.""" - - pass - - -class TestKwargsPropagationToFunctionTool: - """Test cases for kwargs flowing from get_response() to @tool functions.""" - - async def test_kwargs_propagate_to_tool_with_kwargs(self) -> None: - """Test that kwargs passed to get_response() are available in @tool **kwargs.""" - # TODO(Copilot): Remove this legacy coverage once runtime ``**kwargs`` tool injection is removed. - captured_kwargs: dict[str, Any] = {} - - @tool(approval_mode="never_require") - def capture_kwargs_tool(x: int, **kwargs: Any) -> str: - """A tool that captures kwargs for testing.""" - captured_kwargs.update(kwargs) - return f"result: x={x}" - - client = FunctionInvokingMockClient() - client.run_responses = [ - # First response: function call - ChatResponse( - messages=[ - Message( - role="assistant", - contents=[ - Content.from_function_call( - call_id="call_1", name="capture_kwargs_tool", arguments='{"x": 42}' - ) - ], - ) - ] - ), - # Second response: final answer - ChatResponse(messages=[Message(role="assistant", text="Done!")]), - ] - - result = await client.get_response( - messages=[Message(role="user", text="Test")], - stream=False, - options={ - "tools": [capture_kwargs_tool], - "additional_function_arguments": { - "user_id": "user-123", - "session_token": "secret-token", - "custom_data": {"key": "value"}, - }, - }, - ) - - # Verify the tool was called and received the kwargs - assert "user_id" in captured_kwargs, f"Expected 'user_id' in captured kwargs: {captured_kwargs}" - assert captured_kwargs["user_id"] == "user-123" - assert "session_token" in captured_kwargs - assert captured_kwargs["session_token"] == "secret-token" - assert "custom_data" in captured_kwargs - assert captured_kwargs["custom_data"] == {"key": "value"} - # Verify result - assert result.messages[-1].text == "Done!" - - async def test_kwargs_not_forwarded_to_tool_without_kwargs(self) -> None: - """Test that kwargs are NOT forwarded to @tool that doesn't accept **kwargs.""" - # TODO(Copilot): Remove this legacy coverage once runtime ``**kwargs`` tool injection is removed. - - @tool(approval_mode="never_require") - def simple_tool(x: int) -> str: - """A simple tool without **kwargs.""" - return f"result: x={x}" - - client = FunctionInvokingMockClient() - client.run_responses = [ - ChatResponse( - messages=[ - Message( - role="assistant", - contents=[ - Content.from_function_call(call_id="call_1", name="simple_tool", arguments='{"x": 99}') - ], - ) - ] - ), - ChatResponse(messages=[Message(role="assistant", text="Completed!")]), - ] - - # Call with additional_function_arguments - the tool should work but not receive them - result = await client.get_response( - messages=[Message(role="user", text="Test")], - stream=False, - options={ - "tools": [simple_tool], - "additional_function_arguments": {"user_id": "user-123"}, - }, - ) - - # Verify the tool was called successfully (no error from extra kwargs) - assert result.messages[-1].text == "Completed!" - - async def test_kwargs_isolated_between_function_calls(self) -> None: - """Test that kwargs are consistent across multiple function call invocations.""" - # TODO(Copilot): Remove this legacy coverage once runtime ``**kwargs`` tool injection is removed. - invocation_kwargs: list[dict[str, Any]] = [] - - @tool(approval_mode="never_require") - def tracking_tool(name: str, **kwargs: Any) -> str: - """A tool that tracks kwargs from each invocation.""" - invocation_kwargs.append(dict(kwargs)) - return f"called with {name}" - - client = FunctionInvokingMockClient() - client.run_responses = [ - # Two function calls in one response - ChatResponse( - messages=[ - Message( - role="assistant", - contents=[ - Content.from_function_call( - call_id="call_1", name="tracking_tool", arguments='{"name": "first"}' - ), - Content.from_function_call( - call_id="call_2", name="tracking_tool", arguments='{"name": "second"}' - ), - ], - ) - ] - ), - ChatResponse(messages=[Message(role="assistant", text="All done!")]), - ] - - result = await client.get_response( - messages=[Message(role="user", text="Test")], - stream=False, - options={ - "tools": [tracking_tool], - "additional_function_arguments": { - "request_id": "req-001", - "trace_context": {"trace_id": "abc"}, - }, - }, - ) - - # Both invocations should have received the same kwargs - assert len(invocation_kwargs) == 2 - for kwargs in invocation_kwargs: - assert kwargs.get("request_id") == "req-001" - assert kwargs.get("trace_context") == {"trace_id": "abc"} - assert result.messages[-1].text == "All done!" - - async def test_streaming_response_kwargs_propagation(self) -> None: - """Test that kwargs propagate to @tool in streaming mode.""" - # TODO(Copilot): Remove this legacy coverage once runtime ``**kwargs`` tool injection is removed. - captured_kwargs: dict[str, Any] = {} - - @tool(approval_mode="never_require") - def streaming_capture_tool(value: str, **kwargs: Any) -> str: - """A tool that captures kwargs during streaming.""" - captured_kwargs.update(kwargs) - return f"processed: {value}" - - client = FunctionInvokingMockClient() - client.streaming_responses = [ - # First stream: function call - [ - ChatResponseUpdate( - role="assistant", - contents=[ - Content.from_function_call( - call_id="stream_call_1", - name="streaming_capture_tool", - arguments='{"value": "streaming-test"}', - ) - ], - finish_reason="stop", - ) - ], - # Second stream: final response - [ - ChatResponseUpdate( - contents=[Content.from_text("Stream complete!")], role="assistant", finish_reason="stop" - ) - ], - ] - - # Collect streaming updates - updates: list[ChatResponseUpdate] = [] - stream = client.get_response( - messages=[Message(role="user", text="Test")], - stream=True, - options={ - "tools": [streaming_capture_tool], - "additional_function_arguments": { - "streaming_session": "session-xyz", - "correlation_id": "corr-123", - }, - }, - ) - async for update in stream: - updates.append(update) - - # Verify kwargs were captured by the tool - assert "streaming_session" in captured_kwargs, f"Expected 'streaming_session' in {captured_kwargs}" - assert captured_kwargs["streaming_session"] == "session-xyz" - assert captured_kwargs["correlation_id"] == "corr-123" - - async def test_agent_run_injects_function_invocation_context(self) -> None: - """Test that Agent.run injects FunctionInvocationContext for ctx-based tools.""" - captured_context_kwargs: dict[str, Any] = {} - captured_client_kwargs: dict[str, Any] = {} - captured_options: dict[str, Any] = {} - - @tool(approval_mode="never_require") - def capture_context_tool(x: int, ctx: FunctionInvocationContext) -> str: - captured_context_kwargs.update(ctx.kwargs) - return f"result: x={x}" - - class CapturingFunctionInvokingMockClient(FunctionInvokingMockClient): - async def _get_non_streaming_response( - self, - *, - messages: MutableSequence[Message], - options: dict[str, Any], - **kwargs: Any, - ) -> ChatResponse: - captured_options.update(options) - captured_client_kwargs.update(kwargs) - return await super()._get_non_streaming_response(messages=messages, options=options, **kwargs) - - client = CapturingFunctionInvokingMockClient() - client.run_responses = [ - ChatResponse( - messages=[ - Message( - role="assistant", - contents=[ - Content.from_function_call( - call_id="call_1", - name="capture_context_tool", - arguments='{"x": 42}', - ) - ], - ) - ] - ), - ChatResponse(messages=[Message(role="assistant", text="Done!")]), - ] - - agent = Agent(client=client, tools=[capture_context_tool]) - result = await agent.run( - [Message(role="user", text="Test")], - function_invocation_kwargs={"tool_request_id": "tool-123"}, - client_kwargs={"client_request_id": "client-456"}, - ) - - assert captured_context_kwargs["tool_request_id"] == "tool-123" - assert "client_request_id" not in captured_context_kwargs - assert captured_client_kwargs["client_request_id"] == "client-456" - assert "tool_request_id" not in captured_client_kwargs - assert "additional_function_arguments" not in captured_options - assert result.messages[-1].text == "Done!" diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index eb233eea99..09c036c704 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -1751,6 +1751,9 @@ async def test_mcp_tool_sampling_callback_no_valid_content(): assert isinstance(result, types.ErrorData) assert result.code == types.INTERNAL_ERROR assert "Failed to get right content types from the response." in result.message + mock_chat_client.get_response.assert_awaited_once() + _, kwargs = mock_chat_client.get_response.await_args + assert kwargs["options"] == {"max_tokens": None} async def test_mcp_tool_sampling_callback_no_response_and_successful_message_creation(): @@ -3704,14 +3707,19 @@ async def test_mcp_tool_filters_framework_kwargs(): # Invoke the tool with framework kwargs that should be filtered out await func.invoke( - param="test_value", - response_format=MockResponseFormat, # Should be filtered - chat_options={"some": "option"}, # Should be filtered - tools=[Mock()], # Should be filtered - tool_choice="auto", # Should be filtered - session=Mock(), # Should be filtered - conversation_id="conv-123", # Should be filtered - options={"metadata": "value"}, # Should be filtered + context=FunctionInvocationContext( + function=func, + arguments={"param": "test_value"}, + kwargs={ + "response_format": MockResponseFormat, # Should be filtered + "chat_options": {"some": "option"}, # Should be filtered + "tools": [Mock()], # Should be filtered + "tool_choice": "auto", # Should be filtered + "session": Mock(), # Should be filtered + "conversation_id": "conv-123", # Should be filtered + "options": {"metadata": "value"}, # Should be filtered + }, + ), ) # Verify call_tool was called with only the valid argument diff --git a/python/packages/core/tests/core/test_middleware_with_agent.py b/python/packages/core/tests/core/test_middleware_with_agent.py index 6470a8202e..69d08482d3 100644 --- a/python/packages/core/tests/core/test_middleware_with_agent.py +++ b/python/packages/core/tests/core/test_middleware_with_agent.py @@ -789,9 +789,10 @@ class TestChatAgentFunctionMiddlewareWithTools: assert modified_kwargs["new_param"] == "added_by_middleware" assert modified_kwargs["custom_param"] == "test_value" - async def test_run_kwargs_available_in_function_middleware(self, chat_client_base: "MockBaseChatClient") -> None: - """Test that kwargs passed directly to agent.run() appear in FunctionInvocationContext.kwargs, - including complex nested values like dicts.""" + async def test_function_invocation_kwargs_available_in_function_middleware( + self, chat_client_base: "MockBaseChatClient" + ) -> None: + """Test that function_invocation_kwargs appear in FunctionInvocationContext.kwargs.""" captured_kwargs: dict[str, Any] = {} @function_middleware @@ -822,18 +823,20 @@ class TestChatAgentFunctionMiddlewareWithTools: session_metadata = {"tenant": "acme-corp", "region": "us-west"} await agent.run( [Message(role="user", text="Get weather")], - user_id="user-456", - session_metadata=session_metadata, + function_invocation_kwargs={ + "user_id": "user-456", + "session_metadata": session_metadata, + }, ) assert "user_id" in captured_kwargs, f"Expected 'user_id' in kwargs: {captured_kwargs}" assert captured_kwargs["user_id"] == "user-456" assert captured_kwargs["session_metadata"] == {"tenant": "acme-corp", "region": "us-west"} - async def test_run_kwargs_merged_with_additional_function_arguments( + async def test_function_invocation_kwargs_merged_with_additional_function_arguments( self, chat_client_base: "MockBaseChatClient" ) -> None: - """Test that explicit additional_function_arguments in options take precedence over run kwargs.""" + """Test that explicit additional_function_arguments in options take precedence.""" captured_kwargs: dict[str, Any] = {} @function_middleware @@ -863,9 +866,10 @@ class TestChatAgentFunctionMiddlewareWithTools: await agent.run( [Message(role="user", text="Get weather")], - # This kwarg should be overridden by additional_function_arguments - user_id="from-kwargs", - tenant_id="from-kwargs", + function_invocation_kwargs={ + "user_id": "from-kwargs", + "tenant_id": "from-kwargs", + }, options={ "additional_function_arguments": { "user_id": "from-options", @@ -876,15 +880,15 @@ class TestChatAgentFunctionMiddlewareWithTools: # additional_function_arguments takes precedence for overlapping keys assert captured_kwargs["user_id"] == "from-options" - # Non-overlapping kwargs from run() still come through + # Non-overlapping function_invocation_kwargs still come through assert captured_kwargs["tenant_id"] == "from-kwargs" # Keys only in additional_function_arguments are present assert captured_kwargs["extra_key"] == "only-in-options" - async def test_run_kwargs_consistent_across_multiple_tool_calls( + async def test_function_invocation_kwargs_consistent_across_multiple_tool_calls( self, chat_client_base: "MockBaseChatClient" ) -> None: - """Test that kwargs are consistent across multiple tool invocations in a single run.""" + """Test that function_invocation_kwargs are consistent across tool invocations.""" invocation_kwargs: list[dict[str, Any]] = [] @function_middleware @@ -917,8 +921,10 @@ class TestChatAgentFunctionMiddlewareWithTools: await agent.run( [Message(role="user", text="Get weather for both cities")], - user_id="user-456", - request_id="req-001", + function_invocation_kwargs={ + "user_id": "user-456", + "request_id": "req-001", + }, ) assert len(invocation_kwargs) == 2 @@ -2060,23 +2066,21 @@ class TestChatAgentChatMiddleware: "agent_middleware_after", ] - async def test_agent_middleware_can_access_and_override_custom_kwargs(self) -> None: - """Test that agent middleware can access and override custom parameters like temperature.""" - captured_kwargs: dict[str, Any] = {} - modified_kwargs: dict[str, Any] = {} + async def test_agent_middleware_can_access_and_override_options(self) -> None: + """Test that agent middleware can access and override runtime options.""" + captured_options: dict[str, Any] = {} + modified_options: dict[str, Any] = {} @agent_middleware async def kwargs_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: - # Capture the original kwargs - captured_kwargs.update(context.kwargs) + assert isinstance(context.options, dict) + captured_options.update(context.options) - # Modify some kwargs - context.kwargs["temperature"] = 0.9 - context.kwargs["max_tokens"] = 500 - context.kwargs["new_param"] = "added_by_middleware" + context.options["temperature"] = 0.9 + context.options["max_tokens"] = 500 + context.options["new_param"] = "added_by_middleware" - # Store modified kwargs for verification - modified_kwargs.update(context.kwargs) + modified_options.update(context.options) await call_next() @@ -2084,24 +2088,25 @@ class TestChatAgentChatMiddleware: client = MockBaseChatClient() agent = Agent(client=client, middleware=[kwargs_middleware]) - # Execute the agent with custom parameters + # Execute the agent with runtime options messages = [Message(role="user", text="test message")] - response = await agent.run(messages, temperature=0.7, max_tokens=100, custom_param="test_value") + response = await agent.run( + messages, + options={"temperature": 0.7, "max_tokens": 100, "custom_param": "test_value"}, + ) # Verify response assert response is not None assert len(response.messages) > 0 - # Verify middleware captured the original kwargs - assert captured_kwargs["temperature"] == 0.7 - assert captured_kwargs["max_tokens"] == 100 - assert captured_kwargs["custom_param"] == "test_value" + assert captured_options["temperature"] == 0.7 + assert captured_options["max_tokens"] == 100 + assert captured_options["custom_param"] == "test_value" - # Verify middleware could modify the kwargs - assert modified_kwargs["temperature"] == 0.9 - assert modified_kwargs["max_tokens"] == 500 - assert modified_kwargs["new_param"] == "added_by_middleware" - assert modified_kwargs["custom_param"] == "test_value" # Should still be there + assert modified_options["temperature"] == 0.9 + assert modified_options["max_tokens"] == 500 + assert modified_options["new_param"] == "added_by_middleware" + assert modified_options["custom_param"] == "test_value" # class TestMiddlewareWithProtocolOnlyAgent: diff --git a/python/packages/core/tests/core/test_middleware_with_chat.py b/python/packages/core/tests/core/test_middleware_with_chat.py index 5fa9d64031..b3393c2248 100644 --- a/python/packages/core/tests/core/test_middleware_with_chat.py +++ b/python/packages/core/tests/core/test_middleware_with_chat.py @@ -2,6 +2,7 @@ from collections.abc import Awaitable, Callable from typing import Any +from unittest.mock import patch from agent_framework import ( Agent, @@ -296,50 +297,77 @@ class TestChatMiddleware: assert response3 is not None assert execution_count["count"] == 2 # Should be 2 now - async def test_chat_client_middleware_can_access_and_override_custom_kwargs( + async def test_run_level_middleware_is_not_forwarded_to_inner_client( self, chat_client_base: "MockBaseChatClient" ) -> None: - """Test that chat client middleware can access and override custom parameters like temperature.""" - captured_kwargs: dict[str, Any] = {} - modified_kwargs: dict[str, Any] = {} + """Test that run-level middleware stays in the middleware pipeline only.""" + observed_context_kwargs: dict[str, Any] = {} + + @chat_middleware + async def inspecting_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + observed_context_kwargs.update(context.kwargs) + await call_next() + + async def fake_inner_get_response(**kwargs: Any) -> ChatResponse: + assert "middleware" not in kwargs + return ChatResponse(messages=[Message(role="assistant", text="ok")]) + + with patch.object( + chat_client_base, + "_inner_get_response", + side_effect=fake_inner_get_response, + ) as mock_inner_get_response: + response = await chat_client_base.get_response( + [Message(role="user", text="hello")], + client_kwargs={"middleware": [inspecting_middleware], "trace_id": "trace-123"}, + ) + + assert response.messages[0].text == "ok" + assert observed_context_kwargs == {"trace_id": "trace-123"} + mock_inner_get_response.assert_called_once() + + async def test_chat_client_middleware_can_access_and_override_options( + self, chat_client_base: "MockBaseChatClient" + ) -> None: + """Test that chat client middleware can access and override runtime options.""" + captured_options: dict[str, Any] = {} + modified_options: dict[str, Any] = {} @chat_middleware async def kwargs_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: - # Capture the original kwargs - captured_kwargs.update(context.kwargs) + assert isinstance(context.options, dict) + captured_options.update(context.options) - # Modify some kwargs - context.kwargs["temperature"] = 0.9 - context.kwargs["max_tokens"] = 500 - context.kwargs["new_param"] = "added_by_middleware" + context.options["temperature"] = 0.9 + context.options["max_tokens"] = 500 + context.options["new_param"] = "added_by_middleware" - # Store modified kwargs for verification - modified_kwargs.update(context.kwargs) + modified_options.update(context.options) await call_next() # Add middleware to chat client chat_client_base.chat_middleware = [kwargs_middleware] - # Execute chat client with custom parameters + # Execute chat client with runtime options messages = [Message(role="user", text="test message")] response = await chat_client_base.get_response( - messages, temperature=0.7, max_tokens=100, custom_param="test_value" + messages, + options={"temperature": 0.7, "max_tokens": 100, "custom_param": "test_value"}, ) # Verify response assert response is not None assert len(response.messages) > 0 - assert captured_kwargs["temperature"] == 0.7 - assert captured_kwargs["max_tokens"] == 100 - assert captured_kwargs["custom_param"] == "test_value" + assert captured_options["temperature"] == 0.7 + assert captured_options["max_tokens"] == 100 + assert captured_options["custom_param"] == "test_value" - # Verify middleware could modify the kwargs - assert modified_kwargs["temperature"] == 0.9 - assert modified_kwargs["max_tokens"] == 500 - assert modified_kwargs["new_param"] == "added_by_middleware" - assert modified_kwargs["custom_param"] == "test_value" # Should still be there + assert modified_options["temperature"] == 0.9 + assert modified_options["max_tokens"] == 500 + assert modified_options["new_param"] == "added_by_middleware" + assert modified_options["custom_param"] == "test_value" def test_chat_middleware_pipeline_cache_reuses_matching_middleware( self, diff --git a/python/packages/core/tests/core/test_observability.py b/python/packages/core/tests/core/test_observability.py index 7642ffe73a..332ee2b6e6 100644 --- a/python/packages/core/tests/core/test_observability.py +++ b/python/packages/core/tests/core/test_observability.py @@ -207,7 +207,7 @@ async def test_chat_client_observability(mock_chat_client, span_exporter: InMemo messages = [Message(role="user", text="Test message")] span_exporter.clear() - response = await client.get_response(messages=messages, model_id="Test") + response = await client.get_response(messages=messages, options={"model_id": "Test"}) assert response is not None spans = span_exporter.get_finished_spans() assert len(spans) == 1 @@ -232,7 +232,7 @@ async def test_chat_client_streaming_observability( span_exporter.clear() # Collect all yielded updates updates = [] - stream = client.get_response(stream=True, messages=messages, model_id="Test") + stream = client.get_response(stream=True, messages=messages, options={"model_id": "Test"}) async for update in stream: updates.append(update) await stream.get_final_response() @@ -1540,7 +1540,7 @@ async def test_chat_client_observability_exception(mock_chat_client, span_export span_exporter.clear() with pytest.raises(ValueError, match="Test error"): - await client.get_response(messages=messages, model_id="Test") + await client.get_response(messages=messages, options={"model_id": "Test"}) spans = span_exporter.get_finished_spans() assert len(spans) == 1 @@ -1570,7 +1570,7 @@ async def test_chat_client_streaming_observability_exception(mock_chat_client, s span_exporter.clear() with pytest.raises(ValueError, match="Streaming error"): - async for _ in client.get_response(messages=messages, stream=True, model_id="Test"): + async for _ in client.get_response(messages=messages, stream=True, options={"model_id": "Test"}): pass spans = span_exporter.get_finished_spans() @@ -2075,7 +2075,7 @@ async def test_capture_messages_with_finish_reason(mock_chat_client, span_export messages = [Message(role="user", text="Test")] span_exporter.clear() - response = await client.get_response(messages=messages, model_id="Test") + response = await client.get_response(messages=messages, options={"model_id": "Test"}) assert response is not None assert response.finish_reason == "stop" @@ -2165,7 +2165,7 @@ async def test_chat_client_when_disabled(mock_chat_client, span_exporter: InMemo messages = [Message(role="user", text="Test")] span_exporter.clear() - response = await client.get_response(messages=messages, model_id="Test") + response = await client.get_response(messages=messages, options={"model_id": "Test"}) assert response is not None spans = span_exporter.get_finished_spans() @@ -2181,7 +2181,7 @@ async def test_chat_client_streaming_when_disabled(mock_chat_client, span_export span_exporter.clear() updates = [] - async for update in client.get_response(messages=messages, stream=True, model_id="Test"): + async for update in client.get_response(messages=messages, stream=True, options={"model_id": "Test"}): updates.append(update) assert len(updates) == 2 # Still works functionally @@ -2661,7 +2661,7 @@ async def test_capture_messages_preserves_non_ascii_characters(mock_chat_client, messages = [Message(role="user", text=japanese_text)] span_exporter.clear() - response = await client.get_response(messages=messages, model_id="Test") + response = await client.get_response(messages=messages, options={"model_id": "Test"}) assert response is not None spans = span_exporter.get_finished_spans() diff --git a/python/packages/core/tests/core/test_tools.py b/python/packages/core/tests/core/test_tools.py index 859a012e1d..0f87219690 100644 --- a/python/packages/core/tests/core/test_tools.py +++ b/python/packages/core/tests/core/test_tools.py @@ -594,8 +594,8 @@ async def test_tool_invoke_telemetry_sensitive_disabled(span_exporter: InMemoryS assert attributes[OtelAttr.TOOL_CALL_ID] == "test_call_id" -async def test_tool_invoke_ignores_additional_kwargs() -> None: - """Ensure tools drop unknown kwargs when invoked with validated arguments.""" +async def test_tool_invoke_rejects_unexpected_runtime_kwargs() -> None: + """Ensure invoke() requires runtime data to flow through FunctionInvocationContext.""" @tool async def simple_tool(message: str) -> str: @@ -604,15 +604,12 @@ async def test_tool_invoke_ignores_additional_kwargs() -> None: args = simple_tool.input_model(message="hello world") - # These kwargs simulate runtime context passed through function invocation. - result = await simple_tool.invoke( - arguments=args, - api_token="secret-token", - options={"model_id": "dummy"}, - ) - - assert isinstance(result, list) - assert result[0].text == "HELLO WORLD" + with pytest.raises(TypeError, match="Unexpected keyword argument"): + await simple_tool.invoke( + arguments=args, + api_token="secret-token", + options={"model_id": "dummy"}, + ) async def test_tool_invoke_telemetry_with_pydantic_args(span_exporter: InMemorySpanExporter): @@ -917,8 +914,8 @@ def test_parse_inputs_unsupported_type(): # endregion -async def test_ai_function_with_kwargs_injection(): - """Test that ai_function correctly handles kwargs injection and hides them from schema.""" +async def test_ai_function_with_kwargs_rejects_runtime_invoke_kwargs(): + """Test that runtime kwargs must be passed through FunctionInvocationContext.""" @tool def tool_with_kwargs(x: int, **kwargs: Any) -> str: @@ -937,13 +934,11 @@ async def test_ai_function_with_kwargs_injection(): # Verify direct invocation works assert tool_with_kwargs(1, user_id="user1") == "x=1, user=user1" - # Verify invoke works with injected args - result = await tool_with_kwargs.invoke( - arguments=tool_with_kwargs.input_model(x=5), - user_id="user2", - ) - assert isinstance(result, list) - assert result[0].text == "x=5, user=user2" + with pytest.raises(TypeError, match="Unexpected keyword argument"): + await tool_with_kwargs.invoke( + arguments=tool_with_kwargs.input_model(x=5), + user_id="user2", + ) # Verify invoke works without injected args (uses default) result_default = await tool_with_kwargs.invoke( diff --git a/python/packages/devui/tests/devui/conftest.py b/python/packages/devui/tests/devui/conftest.py index 3ff5f499a7..114a7a7d6d 100644 --- a/python/packages/devui/tests/devui/conftest.py +++ b/python/packages/devui/tests/devui/conftest.py @@ -446,7 +446,7 @@ async def executor_with_real_agent() -> tuple[AgentFrameworkExecutor, str, MockB name="Test Chat Agent", description="A real Agent for testing execution flow", client=mock_client, - system_message="You are a helpful test assistant.", + instructions="You are a helpful test assistant.", ) # Register the real agent @@ -478,14 +478,14 @@ async def sequential_workflow() -> tuple[AgentFrameworkExecutor, str, MockBaseCh name="Writer", description="Content writer agent", client=mock_client, - system_message="You are a content writer. Create clear, engaging content.", + instructions="You are a content writer. Create clear, engaging content.", ) reviewer = Agent( id="reviewer", name="Reviewer", description="Content reviewer agent", client=mock_client, - system_message="You are a reviewer. Provide constructive feedback.", + instructions="You are a reviewer. Provide constructive feedback.", ) workflow = SequentialBuilder(participants=[writer, reviewer]).build() @@ -523,21 +523,21 @@ async def concurrent_workflow() -> tuple[AgentFrameworkExecutor, str, MockBaseCh name="Researcher", description="Research agent", client=mock_client, - system_message="You are a researcher. Find key data and insights.", + instructions="You are a researcher. Find key data and insights.", ) analyst = Agent( id="analyst", name="Analyst", description="Analysis agent", client=mock_client, - system_message="You are an analyst. Identify trends and patterns.", + instructions="You are an analyst. Identify trends and patterns.", ) summarizer = Agent( id="summarizer", name="Summarizer", description="Summary agent", client=mock_client, - system_message="You are a summarizer. Provide concise summaries.", + instructions="You are a summarizer. Provide concise summaries.", ) workflow = ConcurrentBuilder(participants=[researcher, analyst, summarizer]).build() diff --git a/python/packages/devui/tests/devui/test_execution.py b/python/packages/devui/tests/devui/test_execution.py index 4d0436a314..fc3abee80d 100644 --- a/python/packages/devui/tests/devui/test_execution.py +++ b/python/packages/devui/tests/devui/test_execution.py @@ -309,7 +309,7 @@ async def test_full_pipeline_workflow_events_are_json_serializable(): name="Serialization Test Agent", description="Agent for testing serialization", client=mock_client, - system_message="You are a test assistant.", + instructions="You are a test assistant.", ) agent_executor = AgentExecutor(id="agent_node", agent=agent) diff --git a/python/packages/durabletask/agent_framework_durabletask/_entities.py b/python/packages/durabletask/agent_framework_durabletask/_entities.py index 460b6b0429..15fb77285e 100644 --- a/python/packages/durabletask/agent_framework_durabletask/_entities.py +++ b/python/packages/durabletask/agent_framework_durabletask/_entities.py @@ -23,6 +23,7 @@ from ._callbacks import AgentCallbackContext, AgentResponseCallbackProtocol from ._durable_agent_state import ( DurableAgentState, DurableAgentStateEntry, + DurableAgentStateMessage, DurableAgentStateRequest, DurableAgentStateResponse, ) @@ -151,10 +152,11 @@ class AgentEntity: try: chat_messages: list[Message] = [ - m.to_chat_message() + replayable_message for entry in self.state.data.conversation_history if not self._is_error_response(entry) for m in entry.messages + if (replayable_message := self._to_replayable_message(m)) is not None ] run_kwargs: dict[str, Any] = {"messages": chat_messages, "options": options} @@ -190,6 +192,21 @@ class AgentEntity: return error_response + @staticmethod + def _to_replayable_message(message: DurableAgentStateMessage) -> Message | None: + """Convert persisted history into a message safe to replay into chat clients.""" + chat_message = message.to_chat_message() + replayable_contents = [content for content in chat_message.contents if content.type != "reasoning"] + if not replayable_contents: + return None + + return Message( + role=chat_message.role, + contents=replayable_contents, + author_name=chat_message.author_name, + additional_properties=chat_message.additional_properties, + ) + async def _invoke_agent( self, run_kwargs: dict[str, Any], diff --git a/python/packages/durabletask/tests/test_durable_entities.py b/python/packages/durabletask/tests/test_durable_entities.py index a11e9718ef..e61eacaf0c 100644 --- a/python/packages/durabletask/tests/test_durable_entities.py +++ b/python/packages/durabletask/tests/test_durable_entities.py @@ -21,7 +21,9 @@ from agent_framework_durabletask import ( DurableAgentStateData, DurableAgentStateMessage, DurableAgentStateRequest, + DurableAgentStateResponse, DurableAgentStateTextContent, + DurableAgentStateTextReasoningContent, RunRequest, ) from agent_framework_durabletask._entities import DurableTaskEntityStateProvider @@ -391,6 +393,54 @@ class TestAgentEntityRunAgent: assert len(history) == 6 assert entity.state.message_count == 6 + async def test_run_filters_reasoning_content_from_replayed_history(self) -> None: + """Replayed durable history should not include reasoning-only content items.""" + captured_messages: list[Message] = [] + + async def mock_run(*args, stream=False, **kwargs): + if stream: + raise TypeError("streaming not supported") + captured_messages.extend(kwargs["messages"]) + return _agent_response("Response") + + mock_agent = Mock() + mock_agent.run = mock_run + + entity = _make_entity(mock_agent) + entity.state.data = DurableAgentStateData( + conversation_history=[ + DurableAgentStateRequest( + correlation_id="corr-entity-prev-request", + created_at=datetime.now(), + messages=[ + DurableAgentStateMessage( + role="user", + contents=[DurableAgentStateTextContent(text="Hi")], + ) + ], + ), + DurableAgentStateResponse( + correlation_id="corr-entity-prev-response", + created_at=datetime.now(), + messages=[ + DurableAgentStateMessage( + role="assistant", + contents=[ + DurableAgentStateTextReasoningContent(text="Let me think."), + DurableAgentStateTextContent(text="Hello there."), + ], + ) + ], + ), + ] + ) + + await entity.run({"message": "What next?", "correlationId": "corr-entity-replay"}) + + assert captured_messages + assert all(content.type != "reasoning" for message in captured_messages for content in message.contents) + assert [message.text for message in captured_messages] == ["Hi", "Hello there.", "What next?"] + class TestAgentEntityReset: """Test suite for the reset operation.""" diff --git a/python/packages/foundry/agent_framework_foundry/_agent.py b/python/packages/foundry/agent_framework_foundry/_agent.py index 67c6f6070d..6f548b4012 100644 --- a/python/packages/foundry/agent_framework_foundry/_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_agent.py @@ -27,6 +27,7 @@ from agent_framework import ( RawAgent, load_settings, ) +from agent_framework._compaction import CompactionStrategy, TokenizerProtocol from agent_framework.observability import AgentTelemetryLayer, ChatTelemetryLayer from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient from azure.ai.projects.aio import AIProjectClient @@ -125,9 +126,13 @@ class RawFoundryAgentChatClient( # type: ignore[misc] credential: AzureCredentialTypes | None = None, project_client: AIProjectClient | None = None, allow_preview: bool | None = None, + default_headers: Mapping[str, str] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - **kwargs: Any, + instruction_role: str | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + additional_properties: dict[str, Any] | None = None, ) -> None: """Initialize a raw Foundry Agent client. @@ -141,9 +146,13 @@ class RawFoundryAgentChatClient( # type: ignore[misc] credential: Azure credential for authentication. project_client: An existing AIProjectClient to use. allow_preview: Enables preview opt-in on internally-created AIProjectClient. + default_headers: Additional HTTP headers for requests made through the OpenAI client. env_file_path: Path to .env file for settings. env_file_encoding: Encoding for .env file. - kwargs: Additional keyword arguments. + instruction_role: The role to use for 'instruction' messages. + compaction_strategy: Optional per-client compaction override. + tokenizer: Optional tokenizer for compaction strategies. + additional_properties: Additional properties stored on the client instance. """ settings = load_settings( FoundryAgentSettings, @@ -189,7 +198,14 @@ class RawFoundryAgentChatClient( # type: ignore[misc] # Get OpenAI client from project async_client = self.project_client.get_openai_client() - super().__init__(async_client=async_client, **kwargs) + super().__init__( + async_client=async_client, + default_headers=default_headers, + instruction_role=instruction_role, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + additional_properties=additional_properties, + ) def _get_agent_reference(self) -> dict[str, str]: """Build the agent reference dict for the Responses API.""" @@ -210,7 +226,10 @@ class RawFoundryAgentChatClient( # type: ignore[misc] default_options: FoundryAgentOptionsT | Mapping[str, Any] | None = None, context_providers: Sequence[BaseContextProvider] | None = None, middleware: Sequence[MiddlewareTypes] | None = None, - **kwargs: Any, + function_invocation_configuration: FunctionInvocationConfiguration | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + additional_properties: Mapping[str, Any] | None = None, ) -> Agent[FoundryAgentOptionsT]: """Create a FoundryAgent that reuses this client's Foundry configuration.""" function_tools = cast( @@ -233,7 +252,10 @@ class RawFoundryAgentChatClient( # type: ignore[misc] description=description, instructions=instructions, default_options=default_options, - **kwargs, + function_invocation_configuration=function_invocation_configuration, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + additional_properties=additional_properties, ), ) @@ -365,11 +387,15 @@ class _FoundryAgentChatClient( # type: ignore[misc] credential: AzureCredentialTypes | None = None, project_client: AIProjectClient | None = None, allow_preview: bool | None = None, + default_headers: Mapping[str, str] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, + instruction_role: str | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + additional_properties: dict[str, Any] | None = None, middleware: (Sequence[ChatAndFunctionMiddlewareTypes] | None) = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, - **kwargs: Any, ) -> None: """Initialize a Foundry Agent client with full middleware support. @@ -380,11 +406,15 @@ class _FoundryAgentChatClient( # type: ignore[misc] credential: Azure credential for authentication. project_client: An existing AIProjectClient to use. allow_preview: Enables preview opt-in on internally-created AIProjectClient. + default_headers: Additional HTTP headers for requests made through the OpenAI client. env_file_path: Path to .env file for settings. env_file_encoding: Encoding for .env file. + instruction_role: The role to use for 'instruction' messages. + compaction_strategy: Optional per-client compaction override. + tokenizer: Optional tokenizer for compaction strategies. + additional_properties: Additional properties stored on the client instance. middleware: Optional sequence of middleware. function_invocation_configuration: Optional function invocation configuration. - kwargs: Additional keyword arguments. """ super().__init__( project_endpoint=project_endpoint, @@ -393,11 +423,15 @@ class _FoundryAgentChatClient( # type: ignore[misc] credential=credential, project_client=project_client, allow_preview=allow_preview, + default_headers=default_headers, env_file_path=env_file_path, env_file_encoding=env_file_encoding, + instruction_role=instruction_role, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + additional_properties=additional_properties, middleware=middleware, function_invocation_configuration=function_invocation_configuration, - **kwargs, ) @@ -435,10 +469,19 @@ class RawFoundryAgent( # type: ignore[misc] allow_preview: bool | None = None, tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None, context_providers: Sequence[BaseContextProvider] | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, client_type: type[RawFoundryAgentChatClient] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - **kwargs: Any, + id: str | None = None, + name: str | None = None, + description: str | None = None, + instructions: str | None = None, + default_options: FoundryAgentOptionsT | Mapping[str, Any] | None = None, + function_invocation_configuration: FunctionInvocationConfiguration | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + additional_properties: Mapping[str, Any] | None = None, ) -> None: """Initialize a Foundry Agent. @@ -454,11 +497,20 @@ class RawFoundryAgent( # type: ignore[misc] allow_preview: Enables preview opt-in on internally-created AIProjectClient. tools: Function tools to provide to the agent. Only ``FunctionTool`` objects are accepted. context_providers: Optional context providers for injecting dynamic context. + middleware: Optional agent-level middleware. client_type: Custom client class to use (must be a subclass of ``RawFoundryAgentChatClient``). Defaults to ``_FoundryAgentChatClient`` (full client middleware). env_file_path: Path to .env file for settings. env_file_encoding: Encoding for .env file. - kwargs: Additional keyword arguments passed to the Agent base class. + id: Optional local agent identifier. + name: Optional display name for the local agent wrapper. + description: Optional local description for the local agent wrapper. + instructions: Optional instructions for the local agent wrapper. + default_options: Default chat options for the local agent wrapper. + function_invocation_configuration: Optional function invocation configuration override. + compaction_strategy: Optional agent-level in-run compaction override. + tokenizer: Optional agent-level tokenizer override. + additional_properties: Additional properties stored on the local agent wrapper. """ # Create the client actual_client_type = client_type or _FoundryAgentChatClient @@ -467,22 +519,38 @@ class RawFoundryAgent( # type: ignore[misc] f"client_type must be a subclass of RawFoundryAgentChatClient, got {actual_client_type.__name__}" ) - client = actual_client_type( - project_endpoint=project_endpoint, - agent_name=agent_name, - agent_version=agent_version, - credential=credential, - project_client=project_client, - allow_preview=allow_preview, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) + client_kwargs: dict[str, Any] = { + "project_endpoint": project_endpoint, + "agent_name": agent_name, + "agent_version": agent_version, + "credential": credential, + "project_client": project_client, + "allow_preview": allow_preview, + "env_file_path": env_file_path, + "env_file_encoding": env_file_encoding, + } + if function_invocation_configuration is not None: + if not issubclass(actual_client_type, FunctionInvocationLayer): + raise TypeError( + "function_invocation_configuration requires a FunctionInvocationLayer-based client_type." + ) + client_kwargs["function_invocation_configuration"] = function_invocation_configuration + + client = actual_client_type(**client_kwargs) super().__init__( client=client, # type: ignore[arg-type] + instructions=instructions, + id=id, + name=name, + description=description, tools=tools, # type: ignore[arg-type] + default_options=cast(FoundryAgentOptionsT | None, default_options), context_providers=context_providers, - **kwargs, + middleware=middleware, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + additional_properties=dict(additional_properties) if additional_properties is not None else None, ) async def configure_azure_monitor( @@ -598,7 +666,15 @@ class FoundryAgent( # type: ignore[misc] client_type: type[RawFoundryAgentChatClient] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - **kwargs: Any, + id: str | None = None, + name: str | None = None, + description: str | None = None, + instructions: str | None = None, + default_options: FoundryAgentOptionsT | Mapping[str, Any] | None = None, + function_invocation_configuration: FunctionInvocationConfiguration | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + additional_properties: Mapping[str, Any] | None = None, ) -> None: """Initialize a Foundry Agent with full middleware and telemetry. @@ -615,7 +691,15 @@ class FoundryAgent( # type: ignore[misc] client_type: Custom client class (must subclass ``RawFoundryAgentChatClient``). env_file_path: Path to .env file for settings. env_file_encoding: Encoding for .env file. - kwargs: Additional keyword arguments. + id: Optional local agent identifier. + name: Optional display name for the local agent wrapper. + description: Optional local description for the local agent wrapper. + instructions: Optional instructions for the local agent wrapper. + default_options: Default chat options for the local agent wrapper. + function_invocation_configuration: Optional function invocation configuration override. + compaction_strategy: Optional agent-level in-run compaction override. + tokenizer: Optional agent-level tokenizer override. + additional_properties: Additional properties stored on the local agent wrapper. """ super().__init__( project_endpoint=project_endpoint, @@ -630,5 +714,13 @@ class FoundryAgent( # type: ignore[misc] client_type=client_type, env_file_path=env_file_path, env_file_encoding=env_file_encoding, - **kwargs, + id=id, + name=name, + description=description, + instructions=instructions, + default_options=default_options, + function_invocation_configuration=function_invocation_configuration, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + additional_properties=additional_properties, ) diff --git a/python/packages/foundry/agent_framework_foundry/_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py index 51d1b96bb3..4634ec8524 100644 --- a/python/packages/foundry/agent_framework_foundry/_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging import sys -from collections.abc import Awaitable, Callable, Sequence +from collections.abc import Awaitable, Callable, Mapping, Sequence from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal from agent_framework import ( @@ -15,6 +15,7 @@ from agent_framework import ( FunctionInvocationLayer, load_settings, ) +from agent_framework._compaction import CompactionStrategy, TokenizerProtocol from agent_framework.observability import ChatTelemetryLayer from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient from azure.ai.projects.aio import AIProjectClient @@ -132,10 +133,13 @@ class RawFoundryChatClient( # type: ignore[misc] model: str | None = None, credential: AzureCredentialTypes | AzureTokenProvider | None = None, allow_preview: bool | None = None, + default_headers: Mapping[str, str] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, instruction_role: str | None = None, - **kwargs: Any, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + additional_properties: dict[str, Any] | None = None, ) -> None: """Initialize a raw Microsoft Foundry chat client. @@ -149,10 +153,13 @@ class RawFoundryChatClient( # type: ignore[misc] credential: Azure credential or token provider for authentication. Required when using ``project_endpoint`` without a ``project_client``. allow_preview: Enables preview opt-in on internally-created AIProjectClient. + default_headers: Additional HTTP headers for requests made through the OpenAI client. env_file_path: Path to .env file for settings. env_file_encoding: Encoding for .env file. instruction_role: The role to use for 'instruction' messages. - kwargs: Additional keyword arguments. + compaction_strategy: Optional per-client compaction override. + tokenizer: Optional tokenizer for compaction strategies. + additional_properties: Additional properties stored on the client instance. """ foundry_settings = load_settings( FoundrySettings, @@ -195,8 +202,11 @@ class RawFoundryChatClient( # type: ignore[misc] super().__init__( model=resolved_model, async_client=project_client.get_openai_client(), + default_headers=default_headers, instruction_role=instruction_role, - **kwargs, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + additional_properties=additional_properties, ) self.project_client = project_client @@ -516,12 +526,15 @@ class FoundryChatClient( # type: ignore[misc] model: str | None = None, credential: AzureCredentialTypes | AzureTokenProvider | None = None, allow_preview: bool | None = None, + default_headers: Mapping[str, str] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, instruction_role: str | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + additional_properties: dict[str, Any] | None = None, middleware: (Sequence[ChatAndFunctionMiddlewareTypes] | None) = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, - **kwargs: Any, ) -> None: """Initialize a Foundry chat client. @@ -533,12 +546,15 @@ class FoundryChatClient( # type: ignore[misc] Can also be set via environment variable ``FOUNDRY_MODEL``. credential: Azure credential or token provider for authentication. allow_preview: Enables preview opt-in on internally-created AIProjectClient. + default_headers: Additional HTTP headers for requests made through the OpenAI client. env_file_path: Path to .env file for settings. env_file_encoding: Encoding for .env file. instruction_role: The role to use for 'instruction' messages. + compaction_strategy: Optional per-client compaction override. + tokenizer: Optional tokenizer for compaction strategies. + additional_properties: Additional properties stored on the client instance. middleware: Optional sequence of middleware. function_invocation_configuration: Optional function invocation configuration. - kwargs: Additional keyword arguments. """ super().__init__( project_endpoint=project_endpoint, @@ -546,10 +562,13 @@ class FoundryChatClient( # type: ignore[misc] model=model, credential=credential, allow_preview=allow_preview, + default_headers=default_headers, env_file_path=env_file_path, env_file_encoding=env_file_encoding, instruction_role=instruction_role, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + additional_properties=additional_properties, middleware=middleware, function_invocation_configuration=function_invocation_configuration, - **kwargs, ) diff --git a/python/packages/foundry/tests/foundry/test_foundry_agent.py b/python/packages/foundry/tests/foundry/test_foundry_agent.py index 2eb992d1a2..09a31f941b 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_agent.py +++ b/python/packages/foundry/tests/foundry/test_foundry_agent.py @@ -2,6 +2,7 @@ from __future__ import annotations +import inspect import os import sys from typing import Any @@ -68,6 +69,17 @@ def test_raw_foundry_agent_chat_client_init_with_agent_name() -> None: assert client.agent_version == "1.0" +def test_raw_foundry_agent_chat_client_init_uses_explicit_parameters() -> None: + signature = inspect.signature(RawFoundryAgentChatClient.__init__) + + assert "default_headers" in signature.parameters + assert "instruction_role" in signature.parameters + assert "compaction_strategy" in signature.parameters + assert "tokenizer" in signature.parameters + assert "additional_properties" in signature.parameters + assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) + + def test_raw_foundry_agent_chat_client_get_agent_reference_with_version() -> None: """Test agent reference includes version when provided.""" @@ -129,6 +141,15 @@ def test_raw_foundry_agent_chat_client_as_agent_preserves_client_type() -> None: assert named_agent.client.agent_name == "test-agent" +def test_raw_foundry_agent_chat_client_as_agent_uses_explicit_parameters() -> None: + signature = inspect.signature(RawFoundryAgentChatClient.as_agent) + + assert "compaction_strategy" in signature.parameters + assert "tokenizer" in signature.parameters + assert "additional_properties" in signature.parameters + assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) + + async def test_raw_foundry_agent_chat_client_prepare_options_validates_tools() -> None: """Test that _prepare_options rejects non-FunctionTool objects.""" @@ -210,6 +231,17 @@ def test_foundry_agent_chat_client_init() -> None: assert client.agent_name == "test-agent" +def test_foundry_agent_chat_client_init_uses_explicit_parameters() -> None: + signature = inspect.signature(_FoundryAgentChatClient.__init__) + + assert "default_headers" in signature.parameters + assert "instruction_role" in signature.parameters + assert "compaction_strategy" in signature.parameters + assert "tokenizer" in signature.parameters + assert "additional_properties" in signature.parameters + assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) + + def test_raw_foundry_agent_init_creates_client() -> None: """Test that RawFoundryAgent creates a client internally.""" @@ -241,6 +273,28 @@ def test_raw_foundry_agent_init_with_custom_client_type() -> None: assert isinstance(agent.client, RawFoundryAgentChatClient) +def test_raw_foundry_agent_init_uses_explicit_parameters() -> None: + signature = inspect.signature(RawFoundryAgent.__init__) + + assert "instructions" in signature.parameters + assert "default_options" in signature.parameters + assert "compaction_strategy" in signature.parameters + assert "tokenizer" in signature.parameters + assert "additional_properties" in signature.parameters + assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) + + +def test_foundry_agent_init_uses_explicit_parameters() -> None: + signature = inspect.signature(FoundryAgent.__init__) + + assert "instructions" in signature.parameters + assert "default_options" in signature.parameters + assert "compaction_strategy" in signature.parameters + assert "tokenizer" in signature.parameters + assert "additional_properties" in signature.parameters + assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) + + def test_raw_foundry_agent_init_rejects_invalid_client_type() -> None: """Test that invalid client_type raises TypeError.""" diff --git a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py index 7489be1896..5691de70e1 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py @@ -2,6 +2,7 @@ from __future__ import annotations +import inspect import json import os import sys @@ -140,6 +141,26 @@ def test_init() -> None: assert client.project_client is mock_project_client +def test_raw_foundry_chat_client_init_uses_explicit_parameters() -> None: + signature = inspect.signature(RawFoundryChatClient.__init__) + + assert "default_headers" in signature.parameters + assert "compaction_strategy" in signature.parameters + assert "tokenizer" in signature.parameters + assert "additional_properties" in signature.parameters + assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) + + +def test_foundry_chat_client_init_uses_explicit_parameters() -> None: + signature = inspect.signature(FoundryChatClient.__init__) + + assert "default_headers" in signature.parameters + assert "compaction_strategy" in signature.parameters + assert "tokenizer" in signature.parameters + assert "additional_properties" in signature.parameters + assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) + + def test_init_with_default_header() -> None: default_headers = {"X-Unit-Test": "test-guid"} mock_openai_client = _make_mock_openai_client() diff --git a/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py b/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py index 1cb16fc40c..5b0e15f2a2 100644 --- a/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py +++ b/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py @@ -3,15 +3,21 @@ from __future__ import annotations import sys -from collections.abc import Sequence -from typing import Any, Generic +from collections.abc import Awaitable, Callable, Mapping, Sequence +from typing import Any, Generic, Literal, cast, overload from agent_framework import ( ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer, ChatOptions, + ChatResponse, + ChatResponseUpdate, + CompactionStrategy, FunctionInvocationConfiguration, FunctionInvocationLayer, + Message, + ResponseStream, + TokenizerProtocol, ) from agent_framework._settings import load_settings from agent_framework.observability import ChatTelemetryLayer @@ -122,8 +128,8 @@ class FoundryLocalSettings(TypedDict, total=False): 'FOUNDRY_LOCAL_'. Keys: - model_id: The name of the model deployment to use. - (Env var FOUNDRY_LOCAL_MODEL_ID) + model: The name of the model deployment to use. + (Env var FOUNDRY_LOCAL_MODEL) """ model: str | None @@ -138,6 +144,78 @@ class FoundryLocalClient( ): """Foundry Local Chat completion class with middleware, telemetry, and function invocation support.""" + @overload + def get_response( + self, + messages: Sequence[Message], + *, + stream: Literal[False] = ..., + options: ChatOptions[ResponseModelT], + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + ) -> Awaitable[ChatResponse[ResponseModelT]]: ... + + @overload + def get_response( + self, + messages: Sequence[Message], + *, + stream: Literal[False] = ..., + options: FoundryLocalChatOptionsT | ChatOptions[None] | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + ) -> Awaitable[ChatResponse[Any]]: ... + + @overload + def get_response( + self, + messages: Sequence[Message], + *, + stream: Literal[True], + options: FoundryLocalChatOptionsT | ChatOptions[Any] | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... + + def get_response( + self, + messages: Sequence[Message], + *, + stream: bool = False, + options: FoundryLocalChatOptionsT | ChatOptions[Any] | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: + """Get a response from the Foundry Local chat client with all standard layers enabled.""" + super_get_response = cast( + "Callable[..., Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]]", + super().get_response, + ) + effective_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} + if middleware is not None: + effective_client_kwargs["middleware"] = middleware + return super_get_response( + messages=messages, + stream=stream, + options=options, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=effective_client_kwargs, + ) + def __init__( self, model: str | None = None, @@ -182,7 +260,7 @@ class FoundryLocalClient( # Create a FoundryLocalClient with a specific model ID: from agent_framework.foundry import FoundryLocalClient - client = FoundryLocalClient(model_id="phi-4-mini") + client = FoundryLocalClient(model="phi-4-mini") agent = client.as_agent( name="LocalAgent", @@ -192,7 +270,7 @@ class FoundryLocalClient( response = await agent.run("What's the weather like in Seattle?") # Or you can set the model id in the environment: - os.environ["FOUNDRY_LOCAL_MODEL_ID"] = "phi-4-mini" + os.environ["FOUNDRY_LOCAL_MODEL"] = "phi-4-mini" client = FoundryLocalClient() # A FoundryLocalManager is created and if set, the service is started. @@ -205,12 +283,12 @@ class FoundryLocalClient( from foundry_local.models import DeviceType client = FoundryLocalClient( - model_id="phi-4-mini", + model="phi-4-mini", device=DeviceType.GPU, ) # and choosing if the model should be prepared on initialization: client = FoundryLocalClient( - model_id="phi-4-mini", + model="phi-4-mini", prepare_model=False, ) # Beware, in this case the first request to generate a completion @@ -230,7 +308,7 @@ class FoundryLocalClient( class MyOptions(FoundryLocalChatOptions, total=False): my_custom_option: str - client: FoundryLocalClient[MyOptions] = FoundryLocalClient(model_id="phi-4-mini") + client: FoundryLocalClient[MyOptions] = FoundryLocalClient(model="phi-4-mini") response = await client.get_response("Hello", options={"my_custom_option": "value"}) Raises: diff --git a/python/packages/foundry_local/tests/test_foundry_local_client.py b/python/packages/foundry_local/tests/test_foundry_local_client.py index c5b4447b28..02b42f22a6 100644 --- a/python/packages/foundry_local/tests/test_foundry_local_client.py +++ b/python/packages/foundry_local/tests/test_foundry_local_client.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +import inspect from unittest.mock import MagicMock, patch import pytest @@ -66,6 +67,15 @@ def test_foundry_local_client_init(mock_foundry_local_manager: MagicMock) -> Non assert isinstance(client, SupportsChatGetResponse) +def test_foundry_local_client_get_response_uses_explicit_runtime_buckets() -> None: + """Foundry Local should expose explicit runtime buckets instead of raw kwargs.""" + signature = inspect.signature(FoundryLocalClient.get_response) + + assert "client_kwargs" in signature.parameters + assert "function_invocation_kwargs" in signature.parameters + assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) + + def test_foundry_local_client_init_with_bootstrap_false(mock_foundry_local_manager: MagicMock) -> None: """Test FoundryLocalClient initialization with bootstrap=False.""" with patch( diff --git a/python/packages/lab/tau2/agent_framework_lab_tau2/runner.py b/python/packages/lab/tau2/agent_framework_lab_tau2/runner.py index 8d4aee310f..6f3faf17c6 100644 --- a/python/packages/lab/tau2/agent_framework_lab_tau2/runner.py +++ b/python/packages/lab/tau2/agent_framework_lab_tau2/runner.py @@ -211,7 +211,7 @@ class TaskRunner: client=assistant_chat_client, instructions=assistant_system_prompt, tools=tools, - temperature=self.assistant_sampling_temperature, + default_options={"temperature": self.assistant_sampling_temperature}, context_providers=[ SlidingWindowHistoryProvider( system_message=assistant_system_prompt, @@ -246,7 +246,7 @@ class TaskRunner: return Agent( client=user_simuator_chat_client, instructions=user_sim_system_prompt, - temperature=0.0, + default_options={"temperature": 0.0}, # No sliding window for user simulator to maintain full conversation context # TODO(yuge): Consider adding user tools in future for more realistic scenarios ) diff --git a/python/packages/openai/agent_framework_openai/__init__.py b/python/packages/openai/agent_framework_openai/__init__.py index 855dfb5f7a..5744c16b43 100644 --- a/python/packages/openai/agent_framework_openai/__init__.py +++ b/python/packages/openai/agent_framework_openai/__init__.py @@ -17,7 +17,7 @@ else: from ._assistant_provider import OpenAIAssistantProvider from ._assistants_client import ( AssistantToolResources, - OpenAIAssistantsClient, + OpenAIAssistantsClient, # type: ignore[reportDeprecated] OpenAIAssistantsOptions, ) from ._chat_client import ( diff --git a/python/packages/openai/agent_framework_openai/_assistant_provider.py b/python/packages/openai/agent_framework_openai/_assistant_provider.py index f0b88e1761..f899607039 100644 --- a/python/packages/openai/agent_framework_openai/_assistant_provider.py +++ b/python/packages/openai/agent_framework_openai/_assistant_provider.py @@ -15,7 +15,7 @@ from openai import AsyncOpenAI from openai.types.beta.assistant import Assistant from pydantic import BaseModel -from ._assistants_client import OpenAIAssistantsClient +from ._assistants_client import OpenAIAssistantsClient # type: ignore[reportDeprecated] from ._shared import OpenAISettings, from_assistant_tools, to_assistant_tools if TYPE_CHECKING: @@ -538,7 +538,7 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]): A configured Agent instance. """ # Create the chat client with the assistant - client = OpenAIAssistantsClient( + client = OpenAIAssistantsClient( # type: ignore[reportDeprecated] model=assistant.model, assistant_id=assistant.id, assistant_name=assistant.name, diff --git a/python/packages/openai/agent_framework_openai/_assistants_client.py b/python/packages/openai/agent_framework_openai/_assistants_client.py index f5755d8640..14aa764492 100644 --- a/python/packages/openai/agent_framework_openai/_assistants_client.py +++ b/python/packages/openai/agent_framework_openai/_assistants_client.py @@ -70,6 +70,11 @@ if sys.version_info >= (3, 12): else: from typing_extensions import override # type: ignore # pragma: no cover +if sys.version_info >= (3, 13): + from warnings import deprecated # type: ignore # pragma: no cover +else: + from typing_extensions import deprecated # type: ignore # pragma: no cover + if sys.version_info >= (3, 11): from typing import Self, TypedDict # type: ignore # pragma: no cover else: @@ -208,6 +213,7 @@ OpenAIAssistantsOptionsT = TypeVar( # endregion +@deprecated("OpenAIAssistantsClient is deprecated. Use OpenAIChatClient instead.") class OpenAIAssistantsClient( # type: ignore[misc] OpenAIConfigMixin, FunctionInvocationLayer[OpenAIAssistantsOptionsT], @@ -216,7 +222,11 @@ class OpenAIAssistantsClient( # type: ignore[misc] BaseChatClient[OpenAIAssistantsOptionsT], Generic[OpenAIAssistantsOptionsT], ): - """OpenAI Assistants client with middleware, telemetry, and function invocation support.""" + """OpenAI Assistants client with middleware, telemetry, and function invocation support. + + .. deprecated:: + OpenAIAssistantsClient is deprecated. Use :class:`OpenAIChatClient` instead. + """ # region Hosted Tool Factory Methods diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 54322cb754..bc7f2f3ba8 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -29,6 +29,7 @@ from typing import ( ) from agent_framework._clients import BaseChatClient +from agent_framework._compaction import CompactionStrategy, TokenizerProtocol from agent_framework._middleware import ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer from agent_framework._settings import SecretString from agent_framework._telemetry import USER_AGENT_KEY @@ -278,6 +279,9 @@ class RawOpenAIChatClient( # type: ignore[misc] default_headers: Mapping[str, str] | None = None, async_client: AsyncOpenAI | None = None, instruction_role: str | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: @@ -295,6 +299,9 @@ class RawOpenAIChatClient( # type: ignore[misc] default_headers: Additional HTTP headers. async_client: Pre-configured OpenAI client. instruction_role: Role for instruction messages (for example ``"system"``). + compaction_strategy: Optional per-client compaction override. + tokenizer: Optional tokenizer for compaction strategies. + additional_properties: Additional properties stored on the client instance. env_file_path: Optional ``.env`` file that is checked before the process environment for ``OPENAI_*`` values. env_file_encoding: Encoding for the ``.env`` file. @@ -314,6 +321,9 @@ class RawOpenAIChatClient( # type: ignore[misc] default_headers: Mapping[str, str] | None = None, async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, instruction_role: str | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: @@ -338,6 +348,9 @@ class RawOpenAIChatClient( # type: ignore[misc] async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. instruction_role: Role for instruction messages (for example ``"system"``). + compaction_strategy: Optional per-client compaction override. + tokenizer: Optional tokenizer for compaction strategies. + additional_properties: Additional properties stored on the client instance. env_file_path: Optional ``.env`` file that is checked before process environment variables for ``AZURE_OPENAI_*`` values. env_file_encoding: Encoding for the ``.env`` file. @@ -358,9 +371,11 @@ class RawOpenAIChatClient( # type: ignore[misc] default_headers: Mapping[str, str] | None = None, async_client: AsyncOpenAI | None = None, instruction_role: str | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - **kwargs: Any, ) -> None: """Initialize a raw OpenAI Chat client. @@ -391,11 +406,13 @@ class RawOpenAIChatClient( # type: ignore[misc] async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. instruction_role: Role for instruction messages (for example ``"system"``). + compaction_strategy: Optional per-client compaction override. + tokenizer: Optional tokenizer for compaction strategies. + additional_properties: Additional properties stored on the client instance. env_file_path: Optional ``.env`` file that is checked before process environment variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*`` lookups. env_file_encoding: Encoding for the ``.env`` file. - kwargs: Additional keyword arguments forwarded to ``BaseChatClient``. Notes: Environment resolution and routing precedence are: @@ -452,7 +469,11 @@ class RawOpenAIChatClient( # type: ignore[misc] if use_azure_client: self.OTEL_PROVIDER_NAME = "azure.ai.openai" # type: ignore[misc] - super().__init__(**kwargs) + super().__init__( + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + additional_properties=additional_properties, + ) # region Inner Methods @@ -460,7 +481,6 @@ class RawOpenAIChatClient( # type: ignore[misc] self, messages: Sequence[Message], options: Mapping[str, Any], - **kwargs: Any, ) -> tuple[AsyncOpenAI, dict[str, Any], dict[str, Any]]: """Validate options and prepare the request. @@ -469,7 +489,7 @@ class RawOpenAIChatClient( # type: ignore[misc] """ client = self.client validated_options = await self._validate_options(options) - run_options = await self._prepare_options(messages, validated_options, **kwargs) + run_options = await self._prepare_options(messages, validated_options) return client, run_options, validated_options def _handle_request_error(self, ex: Exception) -> NoReturn: @@ -526,7 +546,7 @@ class RawOpenAIChatClient( # type: ignore[misc] client, run_options, validated_options, - ) = await self._prepare_request(messages, options, **kwargs) + ) = await self._prepare_request(messages, options) try: if "text_format" in run_options: async with client.responses.stream(**run_options) as response: @@ -560,7 +580,7 @@ class RawOpenAIChatClient( # type: ignore[misc] except Exception as ex: self._handle_request_error(ex) return self._parse_response_from_openai(response, options=validated_options) - client, run_options, validated_options = await self._prepare_request(messages, options, **kwargs) + client, run_options, validated_options = await self._prepare_request(messages, options) try: if "text_format" in run_options: response = await client.responses.parse(stream=False, **run_options) @@ -1121,7 +1141,6 @@ class RawOpenAIChatClient( # type: ignore[misc] self, messages: Sequence[Message], options: Mapping[str, Any], - **kwargs: Any, ) -> dict[str, Any]: """Take options dict and create the specific options for Responses API.""" # Exclude keys that are not supported or handled separately @@ -1143,7 +1162,7 @@ class RawOpenAIChatClient( # type: ignore[misc] # messages # Handle instructions by prepending to messages as system message # Only prepend instructions for the first turn (when no conversation/response ID exists) - conversation_id = self._get_current_conversation_id(options, **kwargs) + conversation_id = options.get("conversation_id") if (instructions := options.get("instructions")) and not conversation_id: # First turn: prepend instructions as system message messages = prepend_instructions_to_messages(list(messages), instructions, role="system") @@ -1151,7 +1170,7 @@ class RawOpenAIChatClient( # type: ignore[misc] request_input = self._prepare_messages_for_openai(messages) if not request_input: raise ChatClientInvalidRequestException("Messages are required for chat completions") - conversation_id = self._get_current_conversation_id(options, **kwargs) + conversation_id = options.get("conversation_id") run_options["input"] = request_input # model id @@ -1169,7 +1188,7 @@ class RawOpenAIChatClient( # type: ignore[misc] run_options[new_key] = run_options.pop(old_key) # Handle different conversation ID formats - if conversation_id := self._get_current_conversation_id(options, **kwargs): + if conversation_id := options.get("conversation_id"): if conversation_id.startswith("resp_"): # For response IDs, set previous_response_id and remove conversation property run_options["previous_response_id"] = conversation_id @@ -1223,14 +1242,6 @@ class RawOpenAIChatClient( # type: ignore[misc] raise ValueError("model must be a non-empty string") options["model"] = self.model - def _get_current_conversation_id(self, options: Mapping[str, Any], **kwargs: Any) -> str | None: - """Get the current conversation ID, preferring kwargs over options. - - This ensures runtime-updated conversation IDs (for example, from tool execution - loops) take precedence over the initial configuration provided in options. - """ - return kwargs.get("conversation_id") or options.get("conversation_id") - def _prepare_messages_for_openai(self, chat_messages: Sequence[Message]) -> list[dict[str, Any]]: """Prepare the chat messages for a request. @@ -2490,10 +2501,13 @@ class OpenAIChatClient( # type: ignore[misc] default_headers: Mapping[str, str] | None = None, async_client: AsyncOpenAI | None = None, instruction_role: str | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, + additional_properties: dict[str, Any] | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, ) -> None: """Initialize an OpenAI Responses client. @@ -2509,11 +2523,14 @@ class OpenAIChatClient( # type: ignore[misc] default_headers: Additional HTTP headers. async_client: Pre-configured OpenAI client. instruction_role: Role for instruction messages (for example ``"system"``). + compaction_strategy: Optional per-client compaction override. + tokenizer: Optional tokenizer for compaction strategies. + middleware: Optional middleware to apply to the client. + function_invocation_configuration: Optional function invocation configuration override. + additional_properties: Optional additional properties to include on all requests. env_file_path: Optional ``.env`` file that is checked before the process environment for ``OPENAI_*`` values. env_file_encoding: Encoding for the ``.env`` file. - middleware: Optional middleware to apply to the client. - function_invocation_configuration: Optional function invocation configuration override. """ ... @@ -2530,10 +2547,13 @@ class OpenAIChatClient( # type: ignore[misc] default_headers: Mapping[str, str] | None = None, async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, instruction_role: str | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, + additional_properties: dict[str, Any] | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, ) -> None: """Initialize an OpenAI Responses client. @@ -2556,11 +2576,14 @@ class OpenAIChatClient( # type: ignore[misc] async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. instruction_role: Role for instruction messages (for example ``"system"``). + compaction_strategy: Optional per-client compaction override. + tokenizer: Optional tokenizer for compaction strategies. + middleware: Optional middleware to apply to the client. + function_invocation_configuration: Optional function invocation configuration override. + additional_properties: Optional additional properties to include on all requests. env_file_path: Optional ``.env`` file that is checked before process environment variables for ``AZURE_OPENAI_*`` values. env_file_encoding: Encoding for the ``.env`` file. - middleware: Optional middleware to apply to the client. - function_invocation_configuration: Optional function invocation configuration override. """ ... @@ -2577,11 +2600,13 @@ class OpenAIChatClient( # type: ignore[misc] default_headers: Mapping[str, str] | None = None, async_client: AsyncOpenAI | None = None, instruction_role: str | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, - **kwargs: Any, + additional_properties: dict[str, Any] | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, ) -> None: """Initialize an OpenAI Responses client. @@ -2611,13 +2636,15 @@ class OpenAIChatClient( # type: ignore[misc] async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. instruction_role: Role to use for instruction messages (for example ``"system"``). + compaction_strategy: Optional per-client compaction override. + tokenizer: Optional tokenizer for compaction strategies. + middleware: Optional middleware to apply to the client. + function_invocation_configuration: Optional function invocation configuration override. + additional_properties: Additional properties stored on the client instance. env_file_path: Optional ``.env`` file that is checked before process environment variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*`` lookups. env_file_encoding: Encoding for the ``.env`` file. - middleware: Optional middleware to apply to the client. - function_invocation_configuration: Optional function invocation configuration override. - kwargs: Other keyword parameters. Notes: Environment resolution and routing precedence are: @@ -2675,7 +2702,9 @@ class OpenAIChatClient( # type: ignore[misc] env_file_encoding=env_file_encoding, middleware=middleware, function_invocation_configuration=function_invocation_configuration, - **kwargs, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + additional_properties=additional_properties, ) diff --git a/python/packages/openai/agent_framework_openai/_chat_completion_client.py b/python/packages/openai/agent_framework_openai/_chat_completion_client.py index 514d0a2991..4828014e5b 100644 --- a/python/packages/openai/agent_framework_openai/_chat_completion_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -18,6 +18,7 @@ from itertools import chain from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, cast, overload from agent_framework._clients import BaseChatClient +from agent_framework._compaction import CompactionStrategy, TokenizerProtocol from agent_framework._docstrings import apply_layered_docstring from agent_framework._middleware import ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer from agent_framework._settings import SecretString @@ -193,6 +194,9 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] default_headers: Mapping[str, str] | None = None, async_client: AsyncOpenAI | None = None, instruction_role: str | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: @@ -210,6 +214,9 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] default_headers: Additional HTTP headers. async_client: Pre-configured OpenAI client. instruction_role: Role for instruction messages (for example ``"system"``). + compaction_strategy: Optional per-client compaction override. + tokenizer: Optional tokenizer for compaction strategies. + additional_properties: Additional properties stored on the client instance. env_file_path: Optional ``.env`` file that is checked before the process environment for ``OPENAI_*`` values. env_file_encoding: Encoding for the ``.env`` file. @@ -229,6 +236,9 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] default_headers: Mapping[str, str] | None = None, async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, instruction_role: str | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: @@ -253,6 +263,9 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. instruction_role: Role for instruction messages (for example ``"system"``). + compaction_strategy: Optional per-client compaction override. + tokenizer: Optional tokenizer for compaction strategies. + additional_properties: Additional properties stored on the client instance. env_file_path: Optional ``.env`` file that is checked before process environment variables for ``AZURE_OPENAI_*`` values. env_file_encoding: Encoding for the ``.env`` file. @@ -273,9 +286,11 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] default_headers: Mapping[str, str] | None = None, async_client: AsyncOpenAI | None = None, instruction_role: str | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - **kwargs: Any, ) -> None: """Initialize a raw OpenAI Chat completion client. @@ -306,11 +321,13 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI and bypasses env lookup. instruction_role: Role for instruction messages (for example ``"system"``). + compaction_strategy: Optional per-client compaction override. + tokenizer: Optional tokenizer for compaction strategies. + additional_properties: Additional properties stored on the client instance. env_file_path: Optional ``.env`` file that is checked before process environment variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*`` lookups. env_file_encoding: Encoding for the ``.env`` file. - kwargs: Additional keyword arguments forwarded to ``BaseChatClient``. Notes: Environment resolution and routing precedence are: @@ -366,7 +383,11 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] if use_azure_client: self.OTEL_PROVIDER_NAME = "azure.ai.openai" # type: ignore[misc] - super().__init__(**kwargs) + super().__init__( + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + additional_properties=additional_properties, + ) # region Hosted Tool Factory Methods @@ -427,7 +448,10 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] *, stream: Literal[False] = ..., options: ChatOptions[ResponseModelBoundT], - **kwargs: Any, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, ) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ... @overload @@ -437,7 +461,10 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] *, stream: Literal[False] = ..., options: OpenAIChatCompletionOptionsT | ChatOptions[None] | None = None, - **kwargs: Any, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, ) -> Awaitable[ChatResponse[Any]]: ... @overload @@ -447,7 +474,10 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] *, stream: Literal[True], options: OpenAIChatCompletionOptionsT | ChatOptions[Any] | None = None, - **kwargs: Any, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... @override @@ -457,7 +487,10 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] *, stream: bool = False, options: OpenAIChatCompletionOptionsT | ChatOptions[Any] | None = None, - **kwargs: Any, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: """Get a response from the raw OpenAI chat client.""" super_get_response = cast( @@ -468,7 +501,10 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc] messages=messages, stream=stream, options=options, - **kwargs, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, ) @override @@ -1205,10 +1241,11 @@ class OpenAIChatCompletionClient( # type: ignore[misc] *, stream: Literal[False] = ..., options: ChatOptions[ResponseModelBoundT], + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, - **kwargs: Any, ) -> Awaitable[ChatResponse[ResponseModelBoundT]]: ... @overload @@ -1218,10 +1255,11 @@ class OpenAIChatCompletionClient( # type: ignore[misc] *, stream: Literal[False] = ..., options: OpenAIChatCompletionOptionsT | ChatOptions[None] | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, - **kwargs: Any, ) -> Awaitable[ChatResponse[Any]]: ... @overload @@ -1231,10 +1269,11 @@ class OpenAIChatCompletionClient( # type: ignore[misc] *, stream: Literal[True], options: OpenAIChatCompletionOptionsT | ChatOptions[Any] | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, - **kwargs: Any, ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... @override @@ -1244,10 +1283,11 @@ class OpenAIChatCompletionClient( # type: ignore[misc] *, stream: bool = False, options: OpenAIChatCompletionOptionsT | ChatOptions[Any] | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, - **kwargs: Any, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: """Get a response from the OpenAI chat client with all standard layers enabled.""" super_get_response = cast( @@ -1261,9 +1301,10 @@ class OpenAIChatCompletionClient( # type: ignore[misc] messages=messages, stream=stream, options=options, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, function_invocation_kwargs=function_invocation_kwargs, client_kwargs=effective_client_kwargs, - **kwargs, ) diff --git a/python/packages/openai/agent_framework_openai/_embedding_client.py b/python/packages/openai/agent_framework_openai/_embedding_client.py index 9cb37ad4df..6a637e29da 100644 --- a/python/packages/openai/agent_framework_openai/_embedding_client.py +++ b/python/packages/openai/agent_framework_openai/_embedding_client.py @@ -79,6 +79,7 @@ class RawOpenAIEmbeddingClient( base_url: str | None = None, default_headers: Mapping[str, str] | None = None, async_client: AsyncOpenAI | None = None, + additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: @@ -95,6 +96,7 @@ class RawOpenAIEmbeddingClient( ``OPENAI_BASE_URL``. default_headers: Additional HTTP headers. async_client: Pre-configured OpenAI client. + additional_properties: Additional properties stored on the client instance. env_file_path: Optional ``.env`` file that is checked before the process environment for ``OPENAI_*`` values. env_file_encoding: Encoding for the ``.env`` file. @@ -113,6 +115,7 @@ class RawOpenAIEmbeddingClient( base_url: str | None = None, default_headers: Mapping[str, str] | None = None, async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, + additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: @@ -136,6 +139,7 @@ class RawOpenAIEmbeddingClient( default_headers: Additional HTTP headers. async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI. + additional_properties: Additional properties stored on the client instance. env_file_path: Optional ``.env`` file that is checked before process environment variables for ``AZURE_OPENAI_*`` values. env_file_encoding: Encoding for the ``.env`` file. @@ -155,9 +159,9 @@ class RawOpenAIEmbeddingClient( api_version: str | None = None, default_headers: Mapping[str, str] | None = None, async_client: AsyncAzureOpenAI | AsyncOpenAI | None = None, + additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, - **kwargs: Any, ) -> None: """Initialize a raw OpenAI embedding client. @@ -187,11 +191,11 @@ class RawOpenAIEmbeddingClient( default_headers: Additional HTTP headers. async_client: Pre-configured client. Passing ``AsyncAzureOpenAI`` keeps the client on Azure; passing ``AsyncOpenAI`` keeps the client on OpenAI. + additional_properties: Additional properties stored on the client instance. env_file_path: Optional ``.env`` file that is checked before process environment variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*`` lookups. env_file_encoding: Encoding for the ``.env`` file. - kwargs: Additional keyword arguments forwarded to ``BaseEmbeddingClient``. Notes: Environment resolution precedence is: @@ -247,7 +251,7 @@ class RawOpenAIEmbeddingClient( if use_azure_client: self.OTEL_PROVIDER_NAME = "azure.ai.openai" # type: ignore[misc] - super().__init__(**kwargs) + super().__init__(additional_properties=additional_properties) def service_url(self) -> str: """Get the URL of the service.""" diff --git a/python/packages/openai/tests/openai/test_openai_assistants_client.py b/python/packages/openai/tests/openai/test_openai_assistants_client.py index 54171ca7ca..ecb211001d 100644 --- a/python/packages/openai/tests/openai/test_openai_assistants_client.py +++ b/python/packages/openai/tests/openai/test_openai_assistants_client.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +import inspect import json import logging from typing import Annotated, Any @@ -11,6 +12,11 @@ from agent_framework import ( Content, Message, SupportsChatGetResponse, + SupportsCodeInterpreterTool, + SupportsFileSearchTool, + SupportsImageGenerationTool, + SupportsMCPTool, + SupportsWebSearchTool, tool, ) from openai.types.beta.threads import ( @@ -30,6 +36,8 @@ from pydantic import Field from agent_framework_openai import OpenAIAssistantsClient +pytestmark = pytest.mark.filterwarnings("ignore:OpenAIAssistantsClient is deprecated\\..*:DeprecationWarning") + def create_test_openai_assistants_client( mock_async_openai: MagicMock, @@ -104,6 +112,25 @@ def mock_async_openai() -> MagicMock: return mock_client +def test_openai_assistants_client_is_deprecated(mock_async_openai: MagicMock) -> None: + with pytest.warns(DeprecationWarning, match="OpenAIAssistantsClient is deprecated. Use OpenAIChatClient instead."): + OpenAIAssistantsClient(model="gpt-4", api_key="test-api-key", async_client=mock_async_openai) + + +def test_openai_assistants_client_init_keeps_var_keyword() -> None: + signature = inspect.signature(OpenAIAssistantsClient.__init__) + + assert any(parameter.kind == inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) + + +def test_openai_assistants_client_supports_code_interpreter_and_file_search() -> None: + assert isinstance(OpenAIAssistantsClient, SupportsCodeInterpreterTool) + assert not isinstance(OpenAIAssistantsClient, SupportsWebSearchTool) + assert not isinstance(OpenAIAssistantsClient, SupportsImageGenerationTool) + assert not isinstance(OpenAIAssistantsClient, SupportsMCPTool) + assert isinstance(OpenAIAssistantsClient, SupportsFileSearchTool) + + def test_init_with_client(mock_async_openai: MagicMock) -> None: """Test OpenAIAssistantsClient initialization with existing client.""" client = create_test_openai_assistants_client( diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index d4e7b6c4a1..87025cffe4 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. import base64 +import inspect import json import os from datetime import datetime, timezone @@ -18,6 +19,11 @@ from agent_framework import ( FunctionTool, Message, SupportsChatGetResponse, + SupportsCodeInterpreterTool, + SupportsFileSearchTool, + SupportsImageGenerationTool, + SupportsMCPTool, + SupportsWebSearchTool, tool, ) from agent_framework._sessions import ( @@ -48,7 +54,7 @@ from openai.types.responses.response_text_delta_event import ResponseTextDeltaEv from pydantic import BaseModel from pytest import param -from agent_framework_openai import OpenAIChatClient +from agent_framework_openai import OpenAIChatClient, OpenAIResponsesClient from agent_framework_openai._chat_client import OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY from agent_framework_openai._exceptions import OpenAIContentFilterException @@ -110,6 +116,40 @@ def test_init(openai_unit_test_env: dict[str, str]) -> None: assert isinstance(openai_responses_client, SupportsChatGetResponse) +def test_init_uses_explicit_parameters() -> None: + signature = inspect.signature(OpenAIChatClient.__init__) + + assert "additional_properties" in signature.parameters + assert "compaction_strategy" in signature.parameters + assert "tokenizer" in signature.parameters + assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) + + +def test_deprecated_responses_client_supports_all_tool_protocols() -> None: + assert isinstance(OpenAIResponsesClient, SupportsCodeInterpreterTool) + assert isinstance(OpenAIResponsesClient, SupportsWebSearchTool) + assert isinstance(OpenAIResponsesClient, SupportsImageGenerationTool) + assert isinstance(OpenAIResponsesClient, SupportsMCPTool) + assert isinstance(OpenAIResponsesClient, SupportsFileSearchTool) + + +def test_protocol_isinstance_with_responses_client_instance() -> None: + client = object.__new__(OpenAIResponsesClient) + + assert isinstance(client, SupportsCodeInterpreterTool) + assert isinstance(client, SupportsWebSearchTool) + + +def test_deprecated_responses_client_tool_methods_return_dict() -> None: + code_tool = OpenAIResponsesClient.get_code_interpreter_tool() + assert isinstance(code_tool, dict) + assert code_tool.get("type") == "code_interpreter" + + web_tool = OpenAIResponsesClient.get_web_search_tool() + assert isinstance(web_tool, dict) + assert web_tool.get("type") == "web_search" + + def test_init_prefers_openai_responses_model(monkeypatch, openai_unit_test_env: dict[str, str]) -> None: monkeypatch.setenv("OPENAI_RESPONSES_MODEL", "test_responses_model_id") @@ -3033,20 +3073,6 @@ async def test_prepare_options_store_parameter_handling() -> None: assert "previous_response_id" not in options -async def test_conversation_id_precedence_kwargs_over_options() -> None: - """When both kwargs and options contain conversation_id, kwargs wins.""" - client = OpenAIChatClient(model="test-model", api_key="test-key") - messages = [Message(role="user", text="Hello")] - - # options has a stale response id, kwargs carries the freshest one - opts = {"conversation_id": "resp_old_123"} - run_opts = await client._prepare_options(messages, opts, conversation_id="resp_new_456") # type: ignore - - # Verify kwargs takes precedence and maps to previous_response_id for resp_* IDs - assert run_opts.get("previous_response_id") == "resp_new_456" - assert "conversation" not in run_opts - - def _create_mock_responses_text_response(*, response_id: str) -> MagicMock: mock_response = MagicMock() mock_response.id = response_id diff --git a/python/packages/openai/tests/openai/test_openai_chat_client_azure.py b/python/packages/openai/tests/openai/test_openai_chat_client_azure.py index 918fe98767..bda022e94a 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client_azure.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client_azure.py @@ -465,7 +465,7 @@ async def test_integration_client_agent_existing_session() -> None: first_response = await first_agent.run( "My hobby is photography. Remember this.", session=session, - store=True, + options={"store": True}, ) assert isinstance(first_response, AgentResponse) @@ -476,7 +476,9 @@ async def test_integration_client_agent_existing_session() -> None: client=OpenAIChatClient(credential=credential), instructions="You are a helpful assistant with good memory.", ) as second_agent: - second_response = await second_agent.run("What is my hobby?", session=preserved_session) + second_response = await second_agent.run( + "What is my hobby?", session=preserved_session, options={"store": True} + ) assert isinstance(second_response, AgentResponse) assert second_response.text is not None diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py index deee60ac7a..18eff3a54f 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +import inspect import json import os from typing import Any @@ -11,6 +12,11 @@ from agent_framework import ( Content, Message, SupportsChatGetResponse, + SupportsCodeInterpreterTool, + SupportsFileSearchTool, + SupportsImageGenerationTool, + SupportsMCPTool, + SupportsWebSearchTool, tool, ) from agent_framework.exceptions import ChatClientException, SettingNotFoundError @@ -20,7 +26,7 @@ from openai.types.chat.chat_completion_message import ChatCompletionMessage from pydantic import BaseModel from pytest import param -from agent_framework_openai import OpenAIChatCompletionClient +from agent_framework_openai import OpenAIChatCompletionClient, RawOpenAIChatCompletionClient from agent_framework_openai._exceptions import OpenAIContentFilterException skip_if_openai_integration_tests_disabled = pytest.mark.skipif( @@ -37,6 +43,41 @@ def test_init(openai_unit_test_env: dict[str, str]) -> None: assert isinstance(open_ai_chat_completion, SupportsChatGetResponse) +def test_get_response_docstring_surfaces_layered_runtime_docs() -> None: + docstring = inspect.getdoc(OpenAIChatCompletionClient.get_response) + + assert docstring is not None + assert "Get a response from a chat client." in docstring + assert "function_invocation_kwargs" in docstring + assert "middleware: Optional per-call chat and function middleware." in docstring + assert "function_middleware: Optional per-call function middleware." not in docstring + + +def test_get_response_is_defined_on_openai_class() -> None: + signature = inspect.signature(OpenAIChatCompletionClient.get_response) + + assert OpenAIChatCompletionClient.get_response.__qualname__ == "OpenAIChatCompletionClient.get_response" + assert "middleware" in signature.parameters + assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) + + +def test_init_uses_explicit_parameters() -> None: + signature = inspect.signature(RawOpenAIChatCompletionClient.__init__) + + assert "additional_properties" in signature.parameters + assert "compaction_strategy" in signature.parameters + assert "tokenizer" in signature.parameters + assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) + + +def test_supports_web_search_only() -> None: + assert not isinstance(OpenAIChatCompletionClient, SupportsCodeInterpreterTool) + assert isinstance(OpenAIChatCompletionClient, SupportsWebSearchTool) + assert not isinstance(OpenAIChatCompletionClient, SupportsImageGenerationTool) + assert not isinstance(OpenAIChatCompletionClient, SupportsMCPTool) + assert not isinstance(OpenAIChatCompletionClient, SupportsFileSearchTool) + + def test_init_prefers_openai_chat_model(monkeypatch, openai_unit_test_env: dict[str, str]) -> None: monkeypatch.setenv("OPENAI_CHAT_MODEL", "test_chat_model_id") diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client_base.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client_base.py index 3f5cbcddfb..76d11d1dbd 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client_base.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client_base.py @@ -138,7 +138,7 @@ async def test_cmc_structured_output_no_fcc( openai_chat_completion = OpenAIChatCompletionClient() await openai_chat_completion.get_response( messages=chat_history, - response_format=Test, + options={"response_format": Test}, ) mock_create.assert_awaited_once() @@ -322,7 +322,7 @@ async def test_get_streaming_structured_output_no_fcc( async for msg in openai_chat_completion.get_response( stream=True, messages=chat_history, - response_format=Test, + options={"response_format": Test}, ): assert isinstance(msg, ChatResponseUpdate) mock_create.assert_awaited_once() diff --git a/python/packages/openai/tests/openai/test_openai_embedding_client.py b/python/packages/openai/tests/openai/test_openai_embedding_client.py index 4ef39697d6..cf2ff4f60f 100644 --- a/python/packages/openai/tests/openai/test_openai_embedding_client.py +++ b/python/packages/openai/tests/openai/test_openai_embedding_client.py @@ -2,6 +2,7 @@ from __future__ import annotations +import inspect import os from unittest.mock import AsyncMock, MagicMock @@ -15,6 +16,7 @@ from agent_framework_openai import ( OpenAIEmbeddingClient, OpenAIEmbeddingOptions, ) +from agent_framework_openai._embedding_client import RawOpenAIEmbeddingClient def _make_openai_response( @@ -44,6 +46,13 @@ def test_openai_construction_with_explicit_params() -> None: assert client.model == "text-embedding-3-small" +def test_raw_openai_embedding_client_init_uses_explicit_parameters() -> None: + signature = inspect.signature(RawOpenAIEmbeddingClient.__init__) + + assert "additional_properties" in signature.parameters + assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) + + def test_openai_construction_from_env(openai_unit_test_env: dict[str, str]) -> None: client = OpenAIEmbeddingClient() assert client.model == openai_unit_test_env["OPENAI_EMBEDDING_MODEL"] diff --git a/python/samples/04-hosting/azure_functions/02_multi_agent/function_app.py b/python/samples/04-hosting/azure_functions/02_multi_agent/function_app.py index eb978d3993..9771a27f70 100644 --- a/python/samples/04-hosting/azure_functions/02_multi_agent/function_app.py +++ b/python/samples/04-hosting/azure_functions/02_multi_agent/function_app.py @@ -1,21 +1,20 @@ # Copyright (c) Microsoft. All rights reserved. -"""Host multiple Foundry-powered agents inside a single Azure Functions app. +"""Host multiple Azure OpenAI-powered agents inside a single Azure Functions app. Components used in this sample: -- FoundryChatClient to create agents bound to a shared Foundry deployment. +- OpenAIChatCompletionClient configured for Azure OpenAI. - AgentFunctionApp to register multiple agents and expose dedicated HTTP endpoints. - Custom tool functions to demonstrate tool invocation from different agents. -Prerequisites: set `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL`, and sign in with Azure CLI before starting the Functions host.""" +Prerequisites: set `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_DEPLOYMENT_NAME`, and sign in with Azure CLI before starting the Functions host.""" import logging -import os from typing import Any from agent_framework import Agent, tool from agent_framework.azure import AgentFunctionApp -from agent_framework.foundry import FoundryChatClient +from agent_framework.openai import OpenAIChatCompletionClient from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv @@ -60,9 +59,7 @@ def calculate_tip(bill_amount: float, tip_percentage: float = 15.0) -> dict[str, # 1. Create multiple agents, each with its own instruction set and tools. -client = FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], +client = OpenAIChatCompletionClient( credential=AzureCliCredential(), ) diff --git a/python/samples/04-hosting/durabletask/02_multi_agent/client.py b/python/samples/04-hosting/durabletask/02_multi_agent/client.py index 5f69e875ed..81933de8ee 100644 --- a/python/samples/04-hosting/durabletask/02_multi_agent/client.py +++ b/python/samples/04-hosting/durabletask/02_multi_agent/client.py @@ -8,7 +8,7 @@ each with their own specialized capabilities and tools. Prerequisites: - The worker must be running with both agents registered -- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL +- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_DEPLOYMENT_NAME when running the worker - Sign in with Azure CLI for AzureCliCredential authentication - Durable Task Scheduler must be running """ diff --git a/python/samples/04-hosting/durabletask/02_multi_agent/sample.py b/python/samples/04-hosting/durabletask/02_multi_agent/sample.py index 47be55a6d9..4ef01fe400 100644 --- a/python/samples/04-hosting/durabletask/02_multi_agent/sample.py +++ b/python/samples/04-hosting/durabletask/02_multi_agent/sample.py @@ -5,7 +5,7 @@ This sample demonstrates running both the worker and client in a single process for multiple agents with different tools. The worker registers two agents (WeatherAgent and MathAgent), each with their own specialized capabilities. Prerequisites: -- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL +- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_DEPLOYMENT_NAME - Sign in with Azure CLI for AzureCliCredential authentication - Durable Task Scheduler must be running (e.g., using Docker) To run this sample: diff --git a/python/samples/04-hosting/durabletask/02_multi_agent/worker.py b/python/samples/04-hosting/durabletask/02_multi_agent/worker.py index ab27cb4edb..9183e9ee61 100644 --- a/python/samples/04-hosting/durabletask/02_multi_agent/worker.py +++ b/python/samples/04-hosting/durabletask/02_multi_agent/worker.py @@ -1,13 +1,13 @@ # Copyright (c) Microsoft. All rights reserved. -"""Worker process for hosting multiple agents with different tools using Durable Task. +"""Worker process for hosting multiple Azure OpenAI agents with different tools using Durable Task. This worker registers two agents - a weather assistant and a math assistant - each with their own specialized tools. This demonstrates how to host multiple agents with different capabilities in a single worker process. Prerequisites: -- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL +- Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_DEPLOYMENT_NAME - Sign in with Azure CLI for AzureCliCredential authentication - Start a Durable Task Scheduler (e.g., using Docker) """ @@ -19,7 +19,7 @@ from typing import Any from agent_framework import Agent, tool from agent_framework.azure import DurableAIAgentWorker -from agent_framework.foundry import FoundryChatClient +from agent_framework.openai import OpenAIChatCompletionClient from azure.identity import AzureCliCredential from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential from dotenv import load_dotenv @@ -73,13 +73,10 @@ def create_weather_agent(): Returns: Agent: The configured Weather agent with weather tool """ - _client = FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], - credential=AsyncAzureCliCredential(), - ) return Agent( - client=_client, + client=OpenAIChatCompletionClient( + credential=AsyncAzureCliCredential(), + ), name=WEATHER_AGENT_NAME, instructions="You are a helpful weather assistant. Provide current weather information.", tools=[get_weather], @@ -92,13 +89,10 @@ def create_math_agent(): Returns: Agent: The configured Math agent with calculation tools """ - _client = FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], - credential=AsyncAzureCliCredential(), - ) return Agent( - client=_client, + client=OpenAIChatCompletionClient( + credential=AsyncAzureCliCredential(), + ), name=MATH_AGENT_NAME, instructions="You are a helpful math assistant. Help users with calculations like tip calculations.", tools=[calculate_tip],