From 1ab600315bf93e1ad3ed105560a7a361660b1392 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:44:23 +0000 Subject: [PATCH 01/85] Bump Aspire.Hosting from 13.5.2 to 13.5.3 (#8029) --- updated-dependencies: - dependency-name: Aspire.Hosting dependency-version: 13.5.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- dotnet/Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 7d8852c2659..47b234fe8a6 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -7,7 +7,7 @@ - 13.5.2 + 13.5.3 From e2633f81ef0ccce2b7586e735daa05a07dd78693 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Thu, 3 Sep 2026 16:32:38 +0000 Subject: [PATCH 02/85] docs: adds missing guidance for public api analyzers updates (#8035) * docs: adds missing guidance for public api analyzers updates * docs: split public API analyzer guidance Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> * docs: aligns RS017 fixture with RS016 Signed-off-by: Vincent Biret --------- Signed-off-by: Vincent Biret Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- CONTRIBUTING.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8126d945eb6..a041b727d58 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -109,6 +109,20 @@ For more details, see the [Package Validation diagnostic IDs](https://learn.micr Released .NET packages also use `Microsoft.CodeAnalysis.PublicApiAnalyzers` to make source-level public API changes visible during builds. The `PublicAPI.*.txt` files use `#nullable enable` so nullability annotations are tracked as part of the public API surface. When adding, changing, or removing public APIs in a released package, update the package's `PublicAPI.Unshipped.txt` file with the analyzer-provided entries and include that change in your PR. The build will fail if public API changes are not reflected in the baseline files. +If local or CI builds report Public API Analyzer warnings or errors, handle each diagnostic separately: + +- `RS0016` reports a newly exposed public API that is missing from the baseline. The preferred fix is to use the analyzer code fix on the affected code symbol to add the missing API entry automatically. Alternatively, run `dotnet format` for `RS0016` from the repository root: + + ```powershell + dotnet format .\dotnet\agent-framework-dotnet.slnx analyzers --diagnostics RS0016 + ``` + +- `RS0017` reports that a declared public API was deleted. Restore the API if the deletion was accidental; otherwise, record the removed signature in the package's `PublicAPI.Unshipped.txt` file with the `*REMOVED*` prefix by using the corresponding code fix, or the following dotnet format script: + + ```powershell + dotnet format .\dotnet\agent-framework-dotnet.slnx analyzers --diagnostics RS0017 + ``` + After a release, the `Promote Shipped APIs` workflow moves entries from `PublicAPI.Unshipped.txt` to `PublicAPI.Shipped.txt` and opens or updates a promotion PR. Publish builds fail if released packages still contain unshipped public API entries. ### Suggested Workflow From 89edc7280266f94151cb3ebd2f093ff207ce5906 Mon Sep 17 00:00:00 2001 From: eoindoherty1 Date: Thu, 3 Sep 2026 18:48:14 +0000 Subject: [PATCH 03/85] Python: [.NET][Python][Purview] Set inline header for requests made with cold scope cache (#7976) * Fix Purview scope cache miss bypass Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Optimize Purview cold scope processing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Run Python Purview cold requests inline Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Keep Purview scope refresh best effort Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/Jobs/ScopeRetrievalJob.cs | 4 - .../ScopedContentProcessor.cs | 23 +-- .../ScopedContentProcessorTests.cs | 144 +++++++++++++----- .../agent_framework_purview/_processor.py | 1 + .../purview/tests/purview/test_processor.py | 1 + 5 files changed, 116 insertions(+), 57 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ScopeRetrievalJob.cs b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ScopeRetrievalJob.cs index c23553f1855..da7aac682d6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ScopeRetrievalJob.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/Models/Jobs/ScopeRetrievalJob.cs @@ -8,10 +8,6 @@ namespace Microsoft.Agents.AI.Purview.Models.Jobs; /// /// Class representing a job that refreshes the protection scopes cache in the background. /// -/// -/// Used by the parallel protection scopes retrieval path to warm the cache without blocking the -/// foreground ProcessContent call. -/// internal sealed class ScopeRetrievalJob : BackgroundJobBase { /// diff --git a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs index fab7c28d9ae..f49f1239a89 100644 --- a/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Purview/ScopedContentProcessor.cs @@ -195,21 +195,22 @@ private async Task ProcessContentWithProtectionScopesAsy ProtectionScopesResponse? cacheResponse = await this._cacheProvider.GetAsync(cacheKey, cancellationToken).ConfigureAwait(false); - if (cacheResponse != null) + if (cacheResponse == null) { - return await this.ProcessWithCachedScopesAsync(pcRequest, cacheResponse, cacheKey, cancellationToken).ConfigureAwait(false); - } + pcRequest.ProcessInline = true; + try + { + this._channelHandler.QueueJob(new ScopeRetrievalJob(psRequest, cacheKey, pcRequest)); + } + catch (PurviewJobException) + { + // QueueJob logs admission failures. Scope refresh is best effort. + } - try - { - this._channelHandler.QueueJob(new ScopeRetrievalJob(psRequest, cacheKey, pcRequest)); - } - catch (PurviewJobException) - { - // QueueJob already logs failures. Scope warmup is best effort; don't block ProcessContent. + return await this.CallProcessContentAsync(pcRequest, cacheKey, dlpActions: null, cancellationToken).ConfigureAwait(false); } - return await this.CallProcessContentAsync(pcRequest, cacheKey, dlpActions: null, cancellationToken).ConfigureAwait(false); + return await this.ProcessWithCachedScopesAsync(pcRequest, cacheResponse, cacheKey, cancellationToken).ConfigureAwait(false); } /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs index d1b9d535589..b59cc7a3929 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Purview.UnitTests/ScopedContentProcessorTests.cs @@ -627,7 +627,7 @@ public async Task ProcessMessagesAsync_UsesTokenUserIdBeforeMessageAdditionalPro this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( It.IsAny(), It.IsAny())) - .ReturnsAsync(new ProtectionScopesResponse { Scopes = [] }); + .ReturnsAsync(CreateApplicableProtectionScopesResponse()); this._mockPurviewClient.Setup(x => x.ProcessContentAsync( It.IsAny(), It.IsAny())) .ReturnsAsync(new ProcessContentResponse { PolicyActions = [] }); @@ -668,7 +668,7 @@ public async Task ProcessMessagesAsync_UsesTokenUserIdBeforeAuthorName_Async() this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( It.IsAny(), It.IsAny())) - .ReturnsAsync(new ProtectionScopesResponse { Scopes = [] }); + .ReturnsAsync(CreateApplicableProtectionScopesResponse()); this._mockPurviewClient.Setup(x => x.ProcessContentAsync( It.IsAny(), It.IsAny())) .ReturnsAsync(new ProcessContentResponse { PolicyActions = [] }); @@ -703,6 +703,9 @@ public async Task ProcessMessagesAsync_UsesProvidedUserId_WhenTokenUserIdIsEmpty It.IsAny(), It.IsAny())) .ReturnsAsync((ProtectionScopesResponse?)null); + this._mockPurviewClient.Setup(x => x.GetProtectionScopesAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(CreateApplicableProtectionScopesResponse()); this._mockPurviewClient.Setup(x => x.ProcessContentAsync( It.IsAny(), It.IsAny())) .ReturnsAsync(new ProcessContentResponse { PolicyActions = [] }); @@ -750,7 +753,7 @@ public async Task ProcessMessagesAsync_ExtractsUserIdFromMessageAuthorName_WhenV } [Fact] - public async Task ProcessMessagesAsync_CacheMiss_QueuesScopeRetrievalJobAndCallsProcessContentAsync() + public async Task ProcessMessagesAsync_CacheMiss_CallsProcessContentInlineAndQueuesScopeRetrievalAsync() { // Arrange var messages = new List @@ -767,19 +770,26 @@ public async Task ProcessMessagesAsync_CacheMiss_QueuesScopeRetrievalJobAndCalls .ReturnsAsync((ProtectionScopesResponse?)null); this._mockPurviewClient.Setup(x => x.ProcessContentAsync( - It.IsAny(), It.IsAny())) + It.Is(request => + request.ScopeIdentifier == null && + request.ProcessInline), + It.IsAny())) .ReturnsAsync(new ProcessContentResponse()); // Act await this._processor.ProcessMessagesAsync( messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None); - // Assert: ProcessContent runs in the foreground; GetProtectionScopes is queued as a background job. + // Assert this._mockPurviewClient.Verify(x => x.ProcessContentAsync( - It.IsAny(), It.IsAny()), Times.Once); + It.Is(request => + request.ScopeIdentifier == null && + request.ProcessInline), + It.IsAny()), Times.Once); this._mockPurviewClient.Verify(x => x.GetProtectionScopesAsync( It.IsAny(), It.IsAny()), Times.Never); - this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny()), Times.Once); + this._mockChannelHandler.Verify(x => x.QueueJob( + It.Is(job => job.ProcessContentRequest.ProcessInline)), Times.Once); } [Fact] @@ -799,17 +809,16 @@ public async Task ProcessMessagesAsync_CacheMiss_WithProcessContentBlockAction_R It.IsAny(), It.IsAny())) .ReturnsAsync((ProtectionScopesResponse?)null); - var pcResponse = new ProcessContentResponse - { - PolicyActions = - [ - new() { Action = DlpAction.BlockAccess } - ] - }; - this._mockPurviewClient.Setup(x => x.ProcessContentAsync( - It.IsAny(), It.IsAny())) - .ReturnsAsync(pcResponse); + It.Is(request => request.ProcessInline), + It.IsAny())) + .ReturnsAsync(new ProcessContentResponse + { + PolicyActions = + [ + new() { Action = DlpAction.BlockAccess } + ] + }); // Act var result = await this._processor.ProcessMessagesAsync( @@ -817,11 +826,13 @@ public async Task ProcessMessagesAsync_CacheMiss_WithProcessContentBlockAction_R // Assert Assert.True(result.shouldBlock); - this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny()), Times.Once); + this._mockPurviewClient.Verify(x => x.ProcessContentAsync( + It.Is(request => request.ProcessInline), + It.IsAny()), Times.Once); } [Fact] - public async Task ProcessMessagesAsync_CacheMiss_StillCallsProcessContentWhenScopeJobCannotQueueAsync() + public async Task ProcessMessagesAsync_CacheMiss_WhenScopeRetrievalCannotQueue_CallsProcessContentInlineAsync() { // Arrange var messages = new List @@ -841,17 +852,19 @@ public async Task ProcessMessagesAsync_CacheMiss_StillCallsProcessContentWhenSco .Throws(new PurviewJobException("queue unavailable")); this._mockPurviewClient.Setup(x => x.ProcessContentAsync( - It.IsAny(), It.IsAny())) + It.Is(request => request.ProcessInline), + It.IsAny())) .ReturnsAsync(new ProcessContentResponse()); // Act await this._processor.ProcessMessagesAsync( messages, "session-123", Activity.UploadText, settings, "user-123", CancellationToken.None); - // Assert: scope warmup is attempted, and ProcessContent still runs when it can't be queued. + // Assert this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny()), Times.Once); this._mockPurviewClient.Verify(x => x.ProcessContentAsync( - It.IsAny(), It.IsAny()), Times.Once); + It.Is(request => request.ProcessInline), + It.IsAny()), Times.Once); } [Fact] @@ -878,11 +891,11 @@ await Assert.ThrowsAsync(() => this._mockPurviewClient.Verify(x => x.ProcessContentAsync( It.IsAny(), It.IsAny()), Times.Never); - this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny()), Times.Never); + this._mockChannelHandler.Verify(x => x.QueueJob(It.IsAny()), Times.Never); } [Fact] - public async Task BackgroundJobRunner_ScopeRetrievalPaymentRequired_CachesForSubsequentCallsAsync() + public async Task BackgroundJobRunner_ScopeRetrievalNoApplicableScopes_CachesAndQueuesContentActivityAsync() { // Arrange Func, Task>? runner = null; @@ -890,40 +903,65 @@ public async Task BackgroundJobRunner_ScopeRetrievalPaymentRequired_CachesForSub Mock purviewClient = new(); Mock cacheProvider = new(); PurviewSettings settings = new("TestApp") { MaxConcurrentJobConsumers = 1 }; - ProtectionScopesRequest request = new("user-123", "tenant-123") - { - Activities = ProtectionScopeActivities.UploadText, - Locations = - [ - new("microsoft.graph.policyLocationApplication", "app-123") - ] - }; + ProtectionScopesRequest request = CreateProtectionScopesRequest(); ProtectionScopesCacheKey cacheKey = new(request); + ScopeRetrievalJob job = new(request, cacheKey, CreateProcessContentRequest()); + ProtectionScopesResponse response = new() { ScopeIdentifier = "scope-123", Scopes = [] }; Channel channel = Channel.CreateUnbounded(); channelHandler.Setup(x => x.AddRunner(It.IsAny, Task>>())) .Callback, Task>>(callback => runner = callback); + purviewClient.Setup(x => x.GetProtectionScopesAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(response); + _ = new BackgroundJobRunner(channelHandler.Object, purviewClient.Object, cacheProvider.Object, NullLogger.Instance, settings); + + // Act + Assert.NotNull(runner); + await channel.Writer.WriteAsync(job); + channel.Writer.Complete(); + await runner(channel); + + // Assert + cacheProvider.Verify(x => x.SetAsync(cacheKey, response, It.IsAny()), Times.Once); + channelHandler.Verify(x => x.QueueJob(It.IsAny()), Times.Once); + } + + [Fact] + public async Task BackgroundJobRunner_ScopeRetrievalApplicableScope_CachesWithoutContentActivityAsync() + { + // Arrange + Func, Task>? runner = null; + Mock channelHandler = new(); + Mock purviewClient = new(); + Mock cacheProvider = new(); + PurviewSettings settings = new("TestApp") { MaxConcurrentJobConsumers = 1 }; + ProtectionScopesRequest request = CreateProtectionScopesRequest(); + ProtectionScopesCacheKey cacheKey = new(request); + ScopeRetrievalJob job = new(request, cacheKey, CreateProcessContentRequest()); + ProtectionScopesResponse response = CreateApplicableProtectionScopesResponse(); + Channel channel = Channel.CreateUnbounded(); + + channelHandler.Setup(x => x.AddRunner(It.IsAny, Task>>())) + .Callback, Task>>(callback => runner = callback); purviewClient.Setup(x => x.GetProtectionScopesAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(new PurviewPaymentRequiredException("Payment required")); + .ReturnsAsync(response); _ = new BackgroundJobRunner(channelHandler.Object, purviewClient.Object, cacheProvider.Object, NullLogger.Instance, settings); // Act Assert.NotNull(runner); - await channel.Writer.WriteAsync(new ScopeRetrievalJob(request, cacheKey, CreateProcessContentRequest())); + await channel.Writer.WriteAsync(job); channel.Writer.Complete(); await runner(channel); // Assert - cacheProvider.Verify(x => x.SetAsync( - It.Is(key => key.TenantId == "tenant-123"), - It.Is(entry => entry.Message == "Payment required"), - It.IsAny()), Times.Once); + cacheProvider.Verify(x => x.SetAsync(cacheKey, response, It.IsAny()), Times.Once); + channelHandler.Verify(x => x.QueueJob(It.IsAny()), Times.Never); } [Fact] - public async Task BackgroundJobRunner_ScopeRetrievalNoApplicableScopes_QueuesContentActivityJobAsync() + public async Task BackgroundJobRunner_ScopeRetrievalPaymentRequired_CachesForSubsequentCallsAsync() { // Arrange Func, Task>? runner = null; @@ -937,9 +975,8 @@ public async Task BackgroundJobRunner_ScopeRetrievalNoApplicableScopes_QueuesCon channelHandler.Setup(x => x.AddRunner(It.IsAny, Task>>())) .Callback, Task>>(callback => runner = callback); - purviewClient.Setup(x => x.GetProtectionScopesAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new ProtectionScopesResponse { Scopes = [] }); + .ThrowsAsync(new PurviewPaymentRequiredException("Payment required")); _ = new BackgroundJobRunner(channelHandler.Object, purviewClient.Object, cacheProvider.Object, NullLogger.Instance, settings); @@ -950,7 +987,10 @@ public async Task BackgroundJobRunner_ScopeRetrievalNoApplicableScopes_QueuesCon await runner(channel); // Assert - channelHandler.Verify(x => x.QueueJob(It.IsAny()), Times.Once); + cacheProvider.Verify(x => x.SetAsync( + It.Is(key => key.TenantId == "tenant-123"), + It.Is(entry => entry.Message == "Payment required"), + It.IsAny()), Times.Once); } #endregion @@ -969,6 +1009,26 @@ private static ProtectionScopesRequest CreateProtectionScopesRequest() }; } + private static ProtectionScopesResponse CreateApplicableProtectionScopesResponse(ExecutionMode executionMode = ExecutionMode.EvaluateInline) + { + return new ProtectionScopesResponse + { + ScopeIdentifier = "scope-123", + Scopes = + [ + new() + { + Activities = ProtectionScopeActivities.UploadText, + Locations = + [ + new("microsoft.graph.policyLocationApplication", "app-123") + ], + ExecutionMode = executionMode + } + ] + }; + } + private static ProcessContentRequest CreateProcessContentRequest() { PurviewTextContent content = new("Test content"); diff --git a/python/packages/purview/agent_framework_purview/_processor.py b/python/packages/purview/agent_framework_purview/_processor.py index 8fda0acf34c..d6c24a078f2 100644 --- a/python/packages/purview/agent_framework_purview/_processor.py +++ b/python/packages/purview/agent_framework_purview/_processor.py @@ -230,6 +230,7 @@ async def _process_with_scopes(self, pc_request: ProcessContentRequest) -> Proce if cached_ps_resp is not None and isinstance(cached_ps_resp, ProtectionScopesResponse): return await self._process_with_cached_scopes(pc_request, cached_ps_resp, cache_key) + pc_request.process_inline = True task = asyncio.create_task(self._refresh_protection_scopes_background(ps_req, cache_key, pc_request)) self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) diff --git a/python/packages/purview/tests/purview/test_processor.py b/python/packages/purview/tests/purview/test_processor.py index e147e8666ab..9d7388a416b 100644 --- a/python/packages/purview/tests/purview/test_processor.py +++ b/python/packages/purview/tests/purview/test_processor.py @@ -283,6 +283,7 @@ async def test_process_with_scopes_calls_client_methods( # On cache miss, ProcessContent runs in the foreground and the response is returned. assert response.id == "response-123" mock_client.process_content.assert_called_once() + assert mock_client.process_content.call_args.args[0].process_inline is True # Protection scopes are refreshed in a background task. await asyncio.gather(*list(processor._background_tasks)) From 02e68f939f73d510fdf6047ddff011cd4121bc02 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:49:21 +0000 Subject: [PATCH 04/85] Update CODEOWNERS (#8033) --- .github/CODEOWNERS | 72 +++++++++++++++++++++++----------------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ffcdefa540a..8f4dc66b86e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -34,19 +34,19 @@ # Repository-level paths: every core Agent Framework developer is a code owner, so any # one of them can approve. Be explicit now and we can use the AgentFramework team in the future. -/docs/ @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @peibekwe @rogerbarreto @SergeyMenshykh -/*.md @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @peibekwe @rogerbarreto @SergeyMenshykh -/LICENSE @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @peibekwe @rogerbarreto @SergeyMenshykh -/.gitattributes @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @peibekwe @rogerbarreto @SergeyMenshykh -/.gitignore @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @peibekwe @rogerbarreto @SergeyMenshykh -/.github @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @peibekwe @rogerbarreto @SergeyMenshykh +/docs/ @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl @peibekwe @baywet @rogerbarreto @SergeyMenshykh +/*.md @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl @peibekwe @baywet @rogerbarreto @SergeyMenshykh +/LICENSE @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl @peibekwe @baywet @rogerbarreto @SergeyMenshykh +/.gitattributes @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl @peibekwe @baywet @rogerbarreto @SergeyMenshykh +/.gitignore @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl @peibekwe @baywet @rogerbarreto @SergeyMenshykh +/.github @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl @peibekwe @baywet @rogerbarreto @SergeyMenshykh # Repository-level paths that require specific owners /.devcontainer @chetantoshniwal @westey-m @rogerbarreto @SergeyMenshykh -/declarative-agents @chetantoshniwal @moonbox3 @peibekwe +/declarative-agents @chetantoshniwal @moonbox3 @eavanvalkenburg @peibekwe @baywet -# Core Python developers: @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU -/python @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU +# Core Python developers: @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl +/python @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl # Python packages /python/packages/a2a/ @chetantoshniwal @giles17 @eavanvalkenburg @moonbox3 @@ -60,50 +60,50 @@ /python/packages/chatkit/ @chetantoshniwal @eavanvalkenburg @moonbox3 @giles17 /python/packages/claude/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 /python/packages/copilotstudio/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 -/python/packages/core/ @chetantoshniwal @eavanvalkenburg @moonbox3 @TaoChenOSU @giles17 -/python/packages/core/agent_framework/_workflows/ @chetantoshniwal @eavanvalkenburg @moonbox3 @TaoChenOSU +/python/packages/core/ @chetantoshniwal @eavanvalkenburg @moonbox3 @TaoChenOSU @jpalvarezl @giles17 +/python/packages/core/agent_framework/_workflows/ @chetantoshniwal @eavanvalkenburg @moonbox3 @TaoChenOSU @jpalvarezl /python/packages/core/agent_framework/_harness/ @chetantoshniwal @westey-m @eavanvalkenburg @moonbox3 -/python/packages/declarative/ @chetantoshniwal @eavanvalkenburg @moonbox3 @peibekwe +/python/packages/declarative/ @chetantoshniwal @eavanvalkenburg @moonbox3 @peibekwe @baywet /python/packages/devui/ @chetantoshniwal @eavanvalkenburg @moonbox3 -/python/packages/foundry/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @moonbox3 @giles17 -/python/packages/foundry_hosting/ @chetantoshniwal @TaoChenOSU @eavanvalkenburg @moonbox3 +/python/packages/foundry/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @jpalvarezl @moonbox3 @giles17 +/python/packages/foundry_hosting/ @chetantoshniwal @TaoChenOSU @jpalvarezl @eavanvalkenburg @moonbox3 /python/packages/foundry_local/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 /python/packages/gemini/ @chetantoshniwal @giles17 @eavanvalkenburg @moonbox3 /python/packages/github_copilot/ @chetantoshniwal @giles17 @eavanvalkenburg @moonbox3 -/python/packages/hosting/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @moonbox3 -/python/packages/hosting-a2a/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @moonbox3 -/python/packages/hosting-mcp/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @moonbox3 -/python/packages/hosting-responses/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @moonbox3 -/python/packages/hosting-telegram/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @moonbox3 +/python/packages/hosting/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @jpalvarezl @moonbox3 +/python/packages/hosting-a2a/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @jpalvarezl @moonbox3 +/python/packages/hosting-mcp/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @jpalvarezl @moonbox3 +/python/packages/hosting-responses/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @jpalvarezl @moonbox3 +/python/packages/hosting-telegram/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @jpalvarezl @moonbox3 /python/packages/hyperlight/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 -/python/packages/lab/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU +/python/packages/lab/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl /python/packages/mem0/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 /python/packages/mistral/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 /python/packages/monty/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 /python/packages/ollama/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 -/python/packages/openai/ @chetantoshniwal @eavanvalkenburg @moonbox3 @TaoChenOSU @giles17 -/python/packages/orchestrations/ @chetantoshniwal @eavanvalkenburg @moonbox3 @TaoChenOSU +/python/packages/openai/ @chetantoshniwal @eavanvalkenburg @moonbox3 @TaoChenOSU @jpalvarezl @giles17 +/python/packages/orchestrations/ @chetantoshniwal @eavanvalkenburg @moonbox3 @TaoChenOSU @jpalvarezl /python/packages/purview/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 /python/packages/redis/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 /python/packages/tools/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 -# Core .NET developers: @peibekwe @rogerbarreto @SergeyMenshykh @westey-m -/dotnet @chetantoshniwal @peibekwe @rogerbarreto @SergeyMenshykh @westey-m +# Core .NET developers: @peibekwe @baywet @rogerbarreto @SergeyMenshykh @westey-m +/dotnet @chetantoshniwal @peibekwe @baywet @rogerbarreto @SergeyMenshykh @westey-m # .NET projects -/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/ @chetantoshniwal @peibekwe @rogerbarreto @SergeyMenshykh @westey-m -/dotnet/src/LegacySupport/ @chetantoshniwal @peibekwe @rogerbarreto @SergeyMenshykh @westey-m -/dotnet/src/Shared/ @chetantoshniwal @peibekwe @rogerbarreto @SergeyMenshykh @westey-m +/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/ @chetantoshniwal @peibekwe @baywet @rogerbarreto @SergeyMenshykh @westey-m +/dotnet/src/LegacySupport/ @chetantoshniwal @peibekwe @baywet @rogerbarreto @SergeyMenshykh @westey-m +/dotnet/src/Shared/ @chetantoshniwal @peibekwe @baywet @rogerbarreto @SergeyMenshykh @westey-m /dotnet/src/Microsoft.Agents.AI/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m /dotnet/src/Microsoft.Agents.AI.A2A/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m /dotnet/src/Microsoft.Agents.AI.Abstractions/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m -/dotnet/src/Microsoft.Agents.AI.AGUI/ @chetantoshniwal @peibekwe @rogerbarreto @SergeyMenshykh @westey-m +/dotnet/src/Microsoft.Agents.AI.AGUI/ @chetantoshniwal @peibekwe @baywet @rogerbarreto @SergeyMenshykh @westey-m /dotnet/src/Microsoft.Agents.AI.Anthropic/ @chetantoshniwal @rogerbarreto @westey-m /dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/ @chetantoshniwal @rogerbarreto @westey-m /dotnet/src/Microsoft.Agents.AI.CopilotStudio/ @chetantoshniwal @rogerbarreto @westey-m /dotnet/src/Microsoft.Agents.AI.CosmosNoSql/ @chetantoshniwal @rogerbarreto @westey-m -/dotnet/src/Microsoft.Agents.AI.Declarative/ @chetantoshniwal @peibekwe @westey-m -/dotnet/src/Microsoft.Agents.AI.DevUI/ @chetantoshniwal @peibekwe @rogerbarreto @SergeyMenshykh @westey-m +/dotnet/src/Microsoft.Agents.AI.Declarative/ @chetantoshniwal @peibekwe @baywet @westey-m +/dotnet/src/Microsoft.Agents.AI.DevUI/ @chetantoshniwal @peibekwe @baywet @rogerbarreto @SergeyMenshykh @westey-m /dotnet/src/Microsoft.Agents.AI.Foundry/ @chetantoshniwal @rogerbarreto @westey-m /dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ @chetantoshniwal @rogerbarreto @westey-m /dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/ @chetantoshniwal @rogerbarreto @westey-m @@ -117,14 +117,14 @@ /dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m /dotnet/src/Microsoft.Agents.AI.Hyperlight/ @chetantoshniwal @westey-m @SergeyMenshykh /dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ @chetantoshniwal @westey-m @SergeyMenshykh -/dotnet/src/Microsoft.Agents.AI.Mcp/ @chetantoshniwal @westey-m @peibekwe +/dotnet/src/Microsoft.Agents.AI.Mcp/ @chetantoshniwal @westey-m @peibekwe @baywet /dotnet/src/Microsoft.Agents.AI.Mem0/ @chetantoshniwal @westey-m @SergeyMenshykh /dotnet/src/Microsoft.Agents.AI.OpenAI/ @chetantoshniwal @westey-m @rogerbarreto /dotnet/src/Microsoft.Agents.AI.Purview/ @chetantoshniwal @westey-m @SergeyMenshykh /dotnet/src/Microsoft.Agents.AI.Tools.Shell/ @chetantoshniwal @westey-m @SergeyMenshykh /dotnet/src/Microsoft.Agents.AI.Valkey/ @chetantoshniwal @westey-m @SergeyMenshykh -/dotnet/src/Microsoft.Agents.AI.Workflows/ @chetantoshniwal @peibekwe @rogerbarreto -/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ @chetantoshniwal @peibekwe @rogerbarreto -/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/ @chetantoshniwal @peibekwe @rogerbarreto -/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/ @chetantoshniwal @peibekwe @rogerbarreto -/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ @chetantoshniwal @peibekwe @rogerbarreto +/dotnet/src/Microsoft.Agents.AI.Workflows/ @chetantoshniwal @peibekwe @baywet @rogerbarreto +/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ @chetantoshniwal @peibekwe @baywet @rogerbarreto +/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/ @chetantoshniwal @peibekwe @baywet @rogerbarreto +/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/ @chetantoshniwal @peibekwe @baywet @rogerbarreto +/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ @chetantoshniwal @peibekwe @baywet @rogerbarreto From fcc07b35cc594a032035617d449d6b066c9d85b4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:39:19 +0000 Subject: [PATCH 05/85] Python: Chore(deps-dev): Bump uv from 0.12.5 to 0.12.6 in /python (#8038) * Chore(deps-dev): Bump uv from 0.12.5 to 0.12.6 in /python Bumps [uv](https://github.com/astral-sh/uv) from 0.12.5 to 0.12.6. - [Release notes](https://github.com/astral-sh/uv/releases) - [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/uv/compare/0.12.5...0.12.6) --- updated-dependencies: - dependency-name: uv dependency-version: 0.12.6 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * Fix lab uv pin and lockfile Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> --- python/packages/lab/pyproject.toml | 2 +- python/pyproject.toml | 2 +- python/uv.lock | 48 +++++++++++++++--------------- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/python/packages/lab/pyproject.toml b/python/packages/lab/pyproject.toml index 1bdb8114b8d..e88eba29fca 100644 --- a/python/packages/lab/pyproject.toml +++ b/python/packages/lab/pyproject.toml @@ -57,7 +57,7 @@ math = [ [dependency-groups] dev = [ - "uv==0.12.5", + "uv==0.12.6", "ruff==0.16.3", "pytest==9.1.1", "mypy==2.3.1", diff --git a/python/pyproject.toml b/python/pyproject.toml index 3346db7a8fb..f0a5dab6d90 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ [dependency-groups] dev = [ - "uv==0.12.5", + "uv==0.12.6", "flit==4.0.2", "ruff==0.16.3", "pytest==9.1.1", diff --git a/python/uv.lock b/python/uv.lock index bc3659bd75a..91736806b4b 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -180,7 +180,7 @@ dev = [ { name = "ruff", specifier = "==0.16.3" }, { name = "tomli", specifier = "==2.4.1" }, { name = "ty", specifier = "==0.0.72" }, - { name = "uv", specifier = "==0.12.5" }, + { name = "uv", specifier = "==0.12.6" }, { name = "zuban", specifier = "==0.9.1" }, ] test = [ @@ -884,7 +884,7 @@ dev = [ { name = "ruff", specifier = "==0.16.3" }, { name = "tomli", specifier = "==2.4.1" }, { name = "tomli-w", specifier = "==1.2.0" }, - { name = "uv", specifier = "==0.12.5" }, + { name = "uv", specifier = "==0.12.6" }, ] tau2 = [{ name = "tau2", git = "https://github.com/sierra-research/tau2-bench?rev=5ba9e3e56db57c5e4114bf7f901291f09b2c5619" }] @@ -7621,28 +7621,28 @@ wheels = [ [[package]] name = "uv" -version = "0.12.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7c/b0/3085b844fe59aa319a3f94a5cca9938fffecc82705aa9c2762a749f7095c/uv-0.12.5.tar.gz", hash = "sha256:442a21d181faae21742aaaf6d2091a0d27755d3eac344061a9a00c90169b7524", size = 7101936, upload-time = "2026-08-14T19:56:57.693Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/4c/6412d4a618230db699118b362ec41c54795f93992b43c53e225bd0213501/uv-0.12.5-py3-none-linux_armv6l.whl", hash = "sha256:2bd62134e56af35b9cf017aaf8ae41a605d6501dd49afc35b70b544a45dd8354", size = 23310055, upload-time = "2026-08-14T19:55:51.357Z" }, - { url = "https://files.pythonhosted.org/packages/bd/ec/d76387b388fa21620088b89b9c67f2596a707add585104e0cb5e8abf55f2/uv-0.12.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:1a06c8bc4d43b5f6c1e3f2ae3d0f6455b07515f762516f95e52e6c0cbccedf15", size = 21401335, upload-time = "2026-08-14T19:55:55.371Z" }, - { url = "https://files.pythonhosted.org/packages/6d/bc/81ab953b7261ae6be40874b1f283a10873871e02eb353d354614dd8da96b/uv-0.12.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d87156bc174d94fae890bb7a261e2867140abb9fe1e9de81a5295e582fb9d0f5", size = 19290641, upload-time = "2026-08-14T19:55:58.998Z" }, - { url = "https://files.pythonhosted.org/packages/7d/13/07585043c10e648820bf826474dac46864ce6691da5dc52fee43c5c7523a/uv-0.12.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:2d65b7b3bc3fd28678f62aa7fb5d90f106ad9782c1354af60b6cecdf9ea9ecd9", size = 22245569, upload-time = "2026-08-14T19:56:02.729Z" }, - { url = "https://files.pythonhosted.org/packages/3e/6d/310f8f56f8d001b4000112a09d7b7de80fb2024a90208fabb9ddc457c123/uv-0.12.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:712624b62e25c84e5a10fc6aa144d8a81b685fdc067a54a7ca4367d75d2cf791", size = 22745152, upload-time = "2026-08-14T19:56:06.426Z" }, - { url = "https://files.pythonhosted.org/packages/92/da/7922b67eec5ee03e94333c5841b682c335033ee80acac17c3417bd752656/uv-0.12.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9656ac7a00fd4314980fb0f790df1c1f3fa9cbcf9af9c6f611b19448b9da687", size = 22787947, upload-time = "2026-08-14T19:56:10.149Z" }, - { url = "https://files.pythonhosted.org/packages/62/55/5dbaed832a4b36809ef8a07c8e56e9fee0dedb0aa0454f6d232b6e468f2c/uv-0.12.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:568485b44e848eb3693f85d6b00299ccd8fc4d26902030dbf24f549c276db9ca", size = 23367616, upload-time = "2026-08-14T19:56:13.768Z" }, - { url = "https://files.pythonhosted.org/packages/11/77/baf761d12bb66efb01706e3bbb5926ed0d13cb0a40539a661fcfffd46de4/uv-0.12.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd08c82831b0033330f8eeeb0d90f938a4d999f25569bee68a975c736142d795", size = 24586263, upload-time = "2026-08-14T19:56:17.57Z" }, - { url = "https://files.pythonhosted.org/packages/c3/a8/76c1031c4834c959bb8a8059c9feabeaa77488ce8b6a3529d6d929ae81cf/uv-0.12.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:edd9ff6154b891146a342c143cd29b330ad97ac6a4b20ff4a99a20a4da84ceca", size = 24160655, upload-time = "2026-08-14T19:56:21.568Z" }, - { url = "https://files.pythonhosted.org/packages/93/22/dacc9a0bc8604187a1ba954a3aef8329e4104eb0af772d2c3c634893bd9b/uv-0.12.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e195ccf1ed60c8bb24a6447ce306441a4181d54b602407e09bc56e963911c15", size = 23657089, upload-time = "2026-08-14T19:56:25.144Z" }, - { url = "https://files.pythonhosted.org/packages/39/98/e8f9c071622f2cb4072d8b587d27b27d23cf0d3ebf8b3687f5af6030f587/uv-0.12.5-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:58abfb0f658b39a834307a11223bc170294ea214263b4c99ecc7663720d43544", size = 22379954, upload-time = "2026-08-14T19:56:28.789Z" }, - { url = "https://files.pythonhosted.org/packages/73/95/4c3f060e95f7cbe9177b4ab361f0cbfc4ae22e5a49b22e73eee9f0d0a6ca/uv-0.12.5-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:6ad2c455f1fe4d2962f6fd7ccb3b1f61c61856681c9d99f40e170b2074353fa3", size = 23318163, upload-time = "2026-08-14T19:56:32.504Z" }, - { url = "https://files.pythonhosted.org/packages/a0/96/ca0497ef8912ef48dbbc9982a8b4212260c34d56bfd0d45fe67b31942121/uv-0.12.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a05b497c2a948c8600f4c831a89852b4d2514b7f561074225cc9edd0cc4811e2", size = 23470437, upload-time = "2026-08-14T19:56:36.525Z" }, - { url = "https://files.pythonhosted.org/packages/60/e7/8bdc37669a6cd2b46a2ec08ccbb58c61395ec84a073e199f5a4a64bb998f/uv-0.12.5-py3-none-musllinux_1_1_i686.whl", hash = "sha256:7817f8e957960f9ddc452ea353f283c0d6393e2e31b400276485adced5b1f371", size = 22545803, upload-time = "2026-08-14T19:56:40.606Z" }, - { url = "https://files.pythonhosted.org/packages/37/cc/01e39e1dbeb838a6b3c26bf97c867d6f366459b22a38bea691af8c6c94c0/uv-0.12.5-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:dc14e4f81a99b585a891350c60d1ff4557d54cb3c3c81fa45fd4e0dd512ba752", size = 23874113, upload-time = "2026-08-14T19:56:44.193Z" }, - { url = "https://files.pythonhosted.org/packages/0a/38/9053599a73a351d1cd34195c7a48c1db4d4d51b57b543607fad7ecf9354c/uv-0.12.5-py3-none-win32.whl", hash = "sha256:39bb102766c95571781a7b4c611675ea213e08df5c680f3936279b3c0d1f6c3c", size = 20744641, upload-time = "2026-08-14T19:56:47.689Z" }, - { url = "https://files.pythonhosted.org/packages/ce/f6/a9af9311c7f5640ca2bfcfdedb7aca37fa6d1d9f5c981fb50c5be02b7477/uv-0.12.5-py3-none-win_amd64.whl", hash = "sha256:455c3e57602e2141e66e2f0bf685898c9c5e5a70377d14c9a71554a3baf3ddbf", size = 21621812, upload-time = "2026-08-14T19:56:51.126Z" }, - { url = "https://files.pythonhosted.org/packages/bc/fb/e1266399f755f97a0783de379f2fed6dae0a2a240db32fe5a2eb976fec8a/uv-0.12.5-py3-none-win_arm64.whl", hash = "sha256:bea86f27a027e0e3af908db4bdd4f1ceef3ca2bd47673b5ccca7f550e325b1b4", size = 20381876, upload-time = "2026-08-14T19:56:54.883Z" }, +version = "0.12.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/26/c2cc420ebadf9a3d3170b6949d21071e2a50761e24b7d352ed2cf9d86de5/uv-0.12.6.tar.gz", hash = "sha256:7c687a6c88b4606b08cd27de29557ee0bf2fff6d2d946e9f1954e8c01745e0b1", size = 7117263, upload-time = "2026-08-25T19:40:02.286Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/94/5326c129967cee589cae13e5aea1a2be0d34b3590ec52cb3fb8693f9ca75/uv-0.12.6-py3-none-linux_armv6l.whl", hash = "sha256:348c35bfe137c539ff8c785e2c98bfb3ad7a2077699abf0b38b3b729386edce7", size = 23081229, upload-time = "2026-08-25T19:38:55.356Z" }, + { url = "https://files.pythonhosted.org/packages/20/8d/dc29316192b7f449ddb0f0796c3f169aa4870b62dc65015d2e3305eecce4/uv-0.12.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:596e24f75b8b81d602228d00c4d7674b53013e94f04e22155f2c5486c3052438", size = 21296317, upload-time = "2026-08-25T19:38:59.08Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5d/4c41b08b4e09729dd50f08b55238d5248a7bfb1a9f7cfba1c162046cfa1c/uv-0.12.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:55bc16b317b2d6044c402e3b1f2c6a8daa031407e9efa7ecbf5348ac0a1c0df5", size = 18115315, upload-time = "2026-08-25T19:39:02.354Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3c/fe2c8b17df5302ea438ac595b5978210bcde9b23adf4fff755e592574fbf/uv-0.12.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:1226946a479a5ae9be4132dafb1a02303be2eb4d39e931d4310088d1c5e4d3a7", size = 22413322, upload-time = "2026-08-25T19:39:05.926Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9d/195a98472a4971fb8045889fa0f04960c89bec22afb42af1f438119e9a4a/uv-0.12.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:4782d51befb745b378e155aafa00976377536c2c28bf214cea2068fc351b9081", size = 22503804, upload-time = "2026-08-25T19:39:09.605Z" }, + { url = "https://files.pythonhosted.org/packages/54/0c/3872d536cd809ffa87af6f3a116399857a48f2664ba4b21b38c5416535df/uv-0.12.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f4f135204aa5236c94348c073fafbb00a7c00c23c25a5ab517aff8e68127dc3", size = 22565554, upload-time = "2026-08-25T19:39:13.286Z" }, + { url = "https://files.pythonhosted.org/packages/77/bc/0fe96033ef905a2729fe512c73c08e541fc0427724777f01c2b934e5cfdd/uv-0.12.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09ce6415e7bf2e9482f66be2d131312b96c72e2e9c4bba2f4084c67515309fed", size = 23136010, upload-time = "2026-08-25T19:39:16.87Z" }, + { url = "https://files.pythonhosted.org/packages/73/91/21756319256364d350ba38f1b3a16bd64ff726865d2d6bb677633fce3cb6/uv-0.12.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db0888a800bfb661c0054c871d04d0b6ef4b8986e01222854c13346371d2b22d", size = 24466274, upload-time = "2026-08-25T19:39:20.723Z" }, + { url = "https://files.pythonhosted.org/packages/5e/49/38883c7734c13d1bc03081ec59c7e9ddd595460ad2fcfbd1a8220e02aa55/uv-0.12.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cb3907b001b0054e8c81621455fb7aca26b4aa1fead35b3165013aadc5eb7ebd", size = 24318234, upload-time = "2026-08-25T19:39:25.019Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6b/dbcffa319e640b3f38fc8764b99031773d4449725ca21854309e436c08f9/uv-0.12.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cb1c4af10a1037d2e875ac86da32ea0df20a9623e11769d33515d14158cd3c2", size = 20927695, upload-time = "2026-08-25T19:39:28.77Z" }, + { url = "https://files.pythonhosted.org/packages/e0/cc/4bde66c453d37e40c5861931f75fb66d207328f5ccbb18d44f818c81d920/uv-0.12.6-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:f8f11ae7d78092384679eac086210d63261af241c56d7c46fa4ded62400e56ee", size = 20273626, upload-time = "2026-08-25T19:39:32.384Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1b/e0c92c3c81b32c7bb70e375b0d218ca6876100cf113765005332454c61f1/uv-0.12.6-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:c874e7077a7a9210531ee361843b7e6a50f8c71a45a3880aab289079042bfbaf", size = 23279870, upload-time = "2026-08-25T19:39:36.485Z" }, + { url = "https://files.pythonhosted.org/packages/65/fc/d24268fa3a39de0d79494fdc8c85000b6ef5b015754fb71b53d5912a2d84/uv-0.12.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ca17e4d2213c4f756a37077776e93202107eb10a71d05e3f9f91f678edcb0239", size = 23375132, upload-time = "2026-08-25T19:39:40.13Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ed/5903a2c3dd56b1c77f97c2d3f1c114f6247db0718d60afb8d08b2c84efa4/uv-0.12.6-py3-none-musllinux_1_1_i686.whl", hash = "sha256:de0c4119af8b7ec15f4d3630d7fb2d9b37fee752093e67a4365df395ffbbf3d7", size = 22276726, upload-time = "2026-08-25T19:39:43.786Z" }, + { url = "https://files.pythonhosted.org/packages/47/51/d9a3032bd233a1562b3bc90bbb5cca2aa6fa9669f4c1056ebe351b2f09fe/uv-0.12.6-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:b7f850c30395026aa024b72a8833133ed71a832fa843c6c59496d8f393cce726", size = 23759803, upload-time = "2026-08-25T19:39:47.72Z" }, + { url = "https://files.pythonhosted.org/packages/e7/b9/e91d756ce38a182e58b5731bcef66a428bc492facd0941cb46be14808830/uv-0.12.6-py3-none-win32.whl", hash = "sha256:99f1a686675befc705530e8cca4a7b066383bd4650967d410abf819ab52bc5ab", size = 20699250, upload-time = "2026-08-25T19:39:51.608Z" }, + { url = "https://files.pythonhosted.org/packages/11/69/c23e5b30db494f9b6e62e8d34ee87da2673c071d4f1518f89e93d52d1e2a/uv-0.12.6-py3-none-win_amd64.whl", hash = "sha256:8fa8fe6d7f4d9a897dd30159426e712094727494fbb7ef435bba3f83030b9fd7", size = 18927528, upload-time = "2026-08-25T19:39:55.299Z" }, + { url = "https://files.pythonhosted.org/packages/d4/41/b5b131a9c96e0c9e129edf30b1daffb3d8f8fbc3e9fc087624ffdd4700ad/uv-0.12.6-py3-none-win_arm64.whl", hash = "sha256:8a93a8c142940c106375cc50743faa7c610b6194e2e9e8da925a61aefb0302e6", size = 20332543, upload-time = "2026-08-25T19:39:59.173Z" }, ] [[package]] From a37340a0b5abb2f6b17eb41f4b3388e6872bf285 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:41:01 +0000 Subject: [PATCH 06/85] Chore(deps-dev): Bump @humanfs/node in /python/packages/devui/frontend (#8019) Bumps [@humanfs/node](https://github.com/humanwhocodes/humanfs/tree/HEAD/packages/node) from 0.16.6 to 0.16.8. - [Release notes](https://github.com/humanwhocodes/humanfs/releases) - [Changelog](https://github.com/humanwhocodes/humanfs/blob/main/packages/node/CHANGELOG.md) - [Commits](https://github.com/humanwhocodes/humanfs/commits/node-v0.16.8/packages/node) --- updated-dependencies: - dependency-name: "@humanfs/node" dependency-version: 0.16.8 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../packages/devui/frontend/package-lock.json | 34 +++++++++---------- python/packages/devui/frontend/yarn.lock | 33 ++++++++++-------- 2 files changed, 35 insertions(+), 32 deletions(-) diff --git a/python/packages/devui/frontend/package-lock.json b/python/packages/devui/frontend/package-lock.json index e9a910f54a6..3e803dc4486 100644 --- a/python/packages/devui/frontend/package-lock.json +++ b/python/packages/devui/frontend/package-lock.json @@ -552,41 +552,41 @@ "license": "MIT" }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", "dev": true, "license": "Apache-2.0", "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "node": ">=18.18.0" } }, "node_modules/@humanwhocodes/module-importer": { diff --git a/python/packages/devui/frontend/yarn.lock b/python/packages/devui/frontend/yarn.lock index aee9b5be61f..3164f0473e0 100644 --- a/python/packages/devui/frontend/yarn.lock +++ b/python/packages/devui/frontend/yarn.lock @@ -322,30 +322,33 @@ resolved "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz" integrity sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ== -"@humanfs/core@^0.19.1": - version "0.19.1" - resolved "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz" - integrity sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA== +"@humanfs/core@^0.19.2": + version "0.19.2" + resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.2.tgz#a8272ca03b2acf492670222b2320b6c421bfde60" + integrity sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA== + dependencies: + "@humanfs/types" "^0.15.0" "@humanfs/node@^0.16.6": - version "0.16.6" - resolved "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz" - integrity sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw== + version "0.16.8" + resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.8.tgz#8f800cccc13f4f8cd3116e2d9c0a94939da3e3ed" + integrity sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ== dependencies: - "@humanfs/core" "^0.19.1" - "@humanwhocodes/retry" "^0.3.0" + "@humanfs/core" "^0.19.2" + "@humanfs/types" "^0.15.0" + "@humanwhocodes/retry" "^0.4.0" + +"@humanfs/types@^0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@humanfs/types/-/types-0.15.0.tgz#f2a09f62012390b2bff3fc6fb248ddec8c09a090" + integrity sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q== "@humanwhocodes/module-importer@^1.0.1": version "1.0.1" resolved "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz" integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== -"@humanwhocodes/retry@^0.3.0": - version "0.3.1" - resolved "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz" - integrity sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA== - -"@humanwhocodes/retry@^0.4.2": +"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2": version "0.4.3" resolved "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz" integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== From c1d269d3f63b703fdc04214a3aa3d6b1ceec2fc9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:41:21 +0000 Subject: [PATCH 07/85] Chore(deps): Bump browserslist in /python/packages/devui/frontend (#8009) Bumps [browserslist](https://github.com/browserslist/browserslist) from 4.25.3 to 4.28.8. - [Release notes](https://github.com/browserslist/browserslist/releases) - [Changelog](https://github.com/browserslist/browserslist/blob/main/CHANGELOG.md) - [Commits](https://github.com/browserslist/browserslist/compare/4.25.3...4.28.8) --- updated-dependencies: - dependency-name: browserslist dependency-version: 4.28.8 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../packages/devui/frontend/package-lock.json | 46 ++++++++-------- python/packages/devui/frontend/yarn.lock | 52 +++++++++++-------- 2 files changed, 52 insertions(+), 46 deletions(-) diff --git a/python/packages/devui/frontend/package-lock.json b/python/packages/devui/frontend/package-lock.json index 3e803dc4486..d420f28df63 100644 --- a/python/packages/devui/frontend/package-lock.json +++ b/python/packages/devui/frontend/package-lock.json @@ -2696,9 +2696,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.37", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", - "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2733,9 +2733,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dev": true, "funding": [ { @@ -2753,11 +2753,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -2777,9 +2777,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001799", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", - "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -3043,9 +3043,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.372", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz", - "integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==", + "version": "1.5.420", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz", + "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==", "dev": true, "license": "ISC" }, @@ -4067,9 +4067,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.47", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", - "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, "license": "MIT", "engines": { @@ -4650,9 +4650,9 @@ "license": "MIT" }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { diff --git a/python/packages/devui/frontend/yarn.lock b/python/packages/devui/frontend/yarn.lock index 3164f0473e0..ccc07de1fce 100644 --- a/python/packages/devui/frontend/yarn.lock +++ b/python/packages/devui/frontend/yarn.lock @@ -1252,6 +1252,11 @@ balanced-match@^1.0.0: resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +baseline-browser-mapping@^2.11.12: + version "2.11.20" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz#26078c7a4b08299656ea7ddceaebec955dc44303" + integrity sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw== + brace-expansion@^1.1.7: version "1.1.16" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.16.tgz#723d3a30c0558c225abc9fc479a73e14e26c3c2f" @@ -1275,24 +1280,25 @@ braces@^3.0.3: fill-range "^7.1.1" browserslist@^4.24.0: - version "4.25.3" - resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.25.3.tgz" - integrity sha512-cDGv1kkDI4/0e5yON9yM5G/0A5u8sf5TnmdX5C9qHzI9PPu++sQ9zjm1k9NiOrf3riY4OkK0zSGqfvJyJsgCBQ== + version "4.28.8" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.8.tgz#a3c79ceb70028527e5da7dafc887f3200b5168c0" + integrity sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA== dependencies: - caniuse-lite "^1.0.30001735" - electron-to-chromium "^1.5.204" - node-releases "^2.0.19" - update-browserslist-db "^1.1.3" + baseline-browser-mapping "^2.11.12" + caniuse-lite "^1.0.30001809" + electron-to-chromium "^1.5.402" + node-releases "^2.0.53" + update-browserslist-db "^1.3.0" callsites@^3.0.0: version "3.1.0" resolved "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz" integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== -caniuse-lite@^1.0.30001735: - version "1.0.30001736" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001736.tgz" - integrity sha512-ImpN5gLEY8gWeqfLUyEF4b7mYWcYoR2Si1VhnrbM4JizRFmfGaAQ12PhNykq6nvI4XvKLrsp8Xde74D5phJOSw== +caniuse-lite@^1.0.30001809: + version "1.0.30001810" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz#4970b477dea3278374de9bc43aa8f5d39fc3cda2" + integrity sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg== chalk@^4.0.0: version "4.1.2" @@ -1439,10 +1445,10 @@ detect-node-es@^1.1.0: resolved "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz" integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ== -electron-to-chromium@^1.5.204: - version "1.5.208" - resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.208.tgz" - integrity sha512-ozZyibehoe7tOhNaf16lKmljVf+3npZcJIEbJRVftVsmAg5TeA1mGS9dVCZzOwr2xT7xK15V0p7+GZqSPgkuPg== +electron-to-chromium@^1.5.402: + version "1.5.420" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz#fc66d26a722d6f227e2092acdf38dd55b198cb44" + integrity sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA== enhanced-resolve@5.21.6: version "5.21.6" @@ -1945,10 +1951,10 @@ next-themes@^0.4.6: resolved "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz" integrity sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA== -node-releases@^2.0.19: - version "2.0.19" - resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz" - integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw== +node-releases@^2.0.53: + version "2.0.54" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.54.tgz#09af17d5647aa9f221ec5cf2becb95b68a981afe" + integrity sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ== optionator@^0.9.3: version "0.9.4" @@ -2233,10 +2239,10 @@ undici-types@~7.10.0: resolved "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz" integrity sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag== -update-browserslist-db@^1.1.3: - version "1.1.3" - resolved "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz" - integrity sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw== +update-browserslist-db@^1.3.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz#9d99fbff56c50bb11ba5fd35cece5916da595836" + integrity sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw== dependencies: escalade "^3.2.0" picocolors "^1.1.1" From 2ce91a077beba574673b64b4019f98dd8dacb633 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Fri, 4 Sep 2026 01:13:10 +0000 Subject: [PATCH 08/85] Python: docs: replace the stale learn-docs placeholder with the live links (#7777) * Python: docs: replace the stale learn-docs placeholder with the live links * Fix the Learn tutorials link --- python/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/python/README.md b/python/README.md index 2a672e5f3c3..13ff3e006aa 100644 --- a/python/README.md +++ b/python/README.md @@ -258,4 +258,6 @@ For more advanced orchestration patterns including Sequential, Concurrent, Group - [Python Package Documentation](https://github.com/microsoft/agent-framework/tree/main/python) - [.NET Package Documentation](https://github.com/microsoft/agent-framework/tree/main/dotnet) - [Design Documents](https://github.com/microsoft/agent-framework/tree/main/docs/design) -- Learn docs are coming soon. +- [Learn: Agent Framework Overview](https://learn.microsoft.com/agent-framework/overview/agent-framework-overview) +- [Learn: Quick Start](https://learn.microsoft.com/agent-framework/tutorials/quick-start) +- [Learn: Tutorials](https://learn.microsoft.com/agent-framework/get-started/) From 3f0d802b515ade9f34ca63bad1df71ab533b8bb8 Mon Sep 17 00:00:00 2001 From: Choppa <259960458+Choppaaahh@users.noreply.github.com> Date: Fri, 4 Sep 2026 05:01:20 +0000 Subject: [PATCH 09/85] Python: Stamp AG-UI checkpoint owner on every save (#8011) * Stamp AG-UI checkpoint owner on every save * Validate checkpoint ownership whenever a checkpoint carries an owner The resume path only compared ownership when the checkpoint had pending request info events, so a checkpoint with an owner but no pending events skipped the check entirely. Combined with stamping ownership on every save, a caller could resume another Snapshot Scope's clean checkpoint and have subsequent saves re-stamped to themselves. Validate on owner presence instead. Checkpoints saved before ownership was recorded have no owner and continue to resume unchanged. --------- Co-authored-by: Choppaaahh --- .../ag-ui/agent_framework_ag_ui/_workflow.py | 14 ++-- .../ag-ui/tests/ag_ui/test_endpoint.py | 77 ++++++++++++++++++- 2 files changed, 82 insertions(+), 9 deletions(-) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_workflow.py b/python/packages/ag-ui/agent_framework_ag_ui/_workflow.py index a5ccd1a9cac..73b2a1d7ec0 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_workflow.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_workflow.py @@ -87,12 +87,11 @@ def __init__(self, storage: CheckpointStorage, owner: WorkflowRequestOwner) -> N async def save(self, checkpoint: WorkflowCheckpoint) -> CheckpointID: """Save a checkpoint with ownership for any pending request occurrences.""" - if checkpoint.pending_request_info_events: - checkpoint.metadata = dict(checkpoint.metadata) - checkpoint.metadata[_CHECKPOINT_REQUEST_OWNER_KEY] = { - "snapshot_scope": self._owner[0], - "thread_id": self._owner[1], - } + checkpoint.metadata = dict(checkpoint.metadata) + checkpoint.metadata[_CHECKPOINT_REQUEST_OWNER_KEY] = { + "snapshot_scope": self._owner[0], + "thread_id": self._owner[1], + } return await self._storage.save(checkpoint) async def load(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint: @@ -398,9 +397,8 @@ async def run(self, input_data: dict[str, Any]) -> AsyncGenerator[BaseEvent]: code="WORKFLOW_CHECKPOINT_LOAD_FAILED", ) return - checkpoint_pending_ids = {str(request_id) for request_id in checkpoint.pending_request_info_events} checkpoint_owner = _checkpoint_request_owner(checkpoint.metadata) - if checkpoint_pending_ids and checkpoint_owner != request_owner: + if checkpoint_owner is not None and checkpoint_owner != request_owner: yield RunStartedEvent(run_id=run_id, thread_id=thread_id) yield RunErrorEvent( message=f"No pending interrupt found for checkpointId '{checkpoint_id}'.", diff --git a/python/packages/ag-ui/tests/ag_ui/test_endpoint.py b/python/packages/ag-ui/tests/ag_ui/test_endpoint.py index 120e387817b..6f38bd8f26e 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_endpoint.py +++ b/python/packages/ag-ui/tests/ag_ui/test_endpoint.py @@ -32,6 +32,7 @@ SupportsAgentRun, ToolApprovalMiddleware, WorkflowBuilder, + WorkflowCheckpoint, WorkflowContext, WorkflowExecutor, executor, @@ -55,7 +56,11 @@ from agent_framework_ag_ui._agent import AgentFrameworkAgent from agent_framework_ag_ui._approval_lifecycle import ApprovalExecutionOwner, ApprovalLifecycle, ApprovalStatus from agent_framework_ag_ui._approval_state import InMemoryAGUIApprovalStateStore, approval_state_thread_id -from agent_framework_ag_ui._workflow import AgentFrameworkWorkflow +from agent_framework_ag_ui._workflow import ( + _CHECKPOINT_REQUEST_OWNER_KEY, + AgentFrameworkWorkflow, + _OwnedWorkflowCheckpointStorage, +) def _decode_sse_events(response: Any) -> list[dict[str, Any]]: @@ -5283,6 +5288,76 @@ async def test_endpoint_workflow_request_info_rejects_unowned_pending_interrupt( assert attacker_errors[0]["code"] == "WORKFLOW_RESUME_NOT_FOUND" +async def test_owned_checkpoint_storage_stamps_owner_without_pending_events() -> None: + """The request owner is stamped on every save, not only when pending request events exist.""" + storage = InMemoryCheckpointStorage() + owned_storage = _OwnedWorkflowCheckpointStorage(storage, ("scope-1", "thread-1")) + checkpoint = WorkflowCheckpoint(workflow_name="owned-workflow", graph_signature_hash="signature") + assert not checkpoint.pending_request_info_events + + checkpoint_id = await owned_storage.save(checkpoint) + + expected_owner = {"snapshot_scope": "scope-1", "thread_id": "thread-1"} + assert checkpoint.metadata[_CHECKPOINT_REQUEST_OWNER_KEY] == expected_owner + stored = await storage.load(checkpoint_id) + assert stored.metadata[_CHECKPOINT_REQUEST_OWNER_KEY] == expected_owner + + +async def test_endpoint_workflow_checkpoint_resume_rejects_foreign_clean_checkpoint(): + """A checkpoint with no pending request events is still owned and cannot be resumed by another thread.""" + storage = InMemoryCheckpointStorage() + first_app = FastAPI() + first_workflow = _build_flight_choice_workflow() + add_agent_framework_fastapi_endpoint( + first_app, + first_workflow, + path="/workflow", + checkpoint_storage=storage, + ) + + with TestClient(first_app) as client: + pause_response = client.post( + "/workflow", + json={ + "runId": "run-pause", + "threadId": "victim-thread", + "messages": [{"role": "user", "content": "Book me a flight"}], + }, + ) + assert pause_response.status_code == 200 + + checkpoints = await storage.list_checkpoints(workflow_name=first_workflow.name) + clean_checkpoints = [checkpoint for checkpoint in checkpoints if not checkpoint.pending_request_info_events] + assert clean_checkpoints, "expected at least one checkpoint without pending request events" + checkpoint = min(clean_checkpoints, key=lambda checkpoint: checkpoint.timestamp) + assert checkpoint.metadata.get(_CHECKPOINT_REQUEST_OWNER_KEY) is not None + + second_app = FastAPI() + add_agent_framework_fastapi_endpoint( + second_app, + _build_flight_choice_workflow(), + path="/workflow", + checkpoint_storage=storage, + ) + + with TestClient(second_app) as client: + attacker_response = client.post( + "/workflow", + json={ + "runId": "run-attacker", + "threadId": "attacker-thread", + "messages": [], + "forwardedProps": {"checkpointId": checkpoint.checkpoint_id}, + }, + ) + + attacker_events = _decode_sse_events(attacker_response) + attacker_errors = [event for event in attacker_events if event.get("type") == "RUN_ERROR"] + assert len(attacker_errors) == 1 + assert attacker_errors[0]["code"] == "WORKFLOW_RESUME_NOT_FOUND" + assert not [event for event in attacker_events if event.get("type") == "TEXT_MESSAGE_CONTENT"] + + async def test_endpoint_workflow_checkpoint_resume_rejects_threaded_resume_after_restart(): """An explicitly threaded cold checkpoint resume fails closed when ownership is unavailable.""" storage = InMemoryCheckpointStorage() From 43f8c056a91f9ab4b6c4724bbc89777156945758 Mon Sep 17 00:00:00 2001 From: karthikchundi-commits Date: Fri, 4 Sep 2026 05:45:59 +0000 Subject: [PATCH 10/85] Python: wrap Anthropic and Gemini SDK exceptions in ChatClientException (#7855) * Wrap Anthropic and Gemini SDK exceptions in ChatClientException _inner_get_response() in the OpenAI, Mistral, Ollama, and Bedrock chat clients all translate raw provider SDK exceptions into the framework's ChatClientException hierarchy (ChatClientInvalidAuthException for auth failures, ChatClientInvalidRequestException for bad requests, ChatClientException otherwise). Mistral's test suite explicitly asserts this behavior (test_get_response_http_error_wrapped, test_get_response_network_error_wrapped). The Anthropic and Gemini clients call their SDKs directly with no try/except at all, so a raw anthropic.APIError or google.genai.errors.APIError (and subclasses) propagates unwrapped. Code that catches ChatClientException to handle chat-client failures in a provider-agnostic way - the documented purpose of that base class - silently fails to catch failures from these two providers. Wraps both the streaming and non-streaming call sites in both clients, using each SDK's real exception hierarchy (verified against the installed anthropic and google-genai packages, not assumed) to classify auth vs. bad-request vs. other errors the same way Mistral already does. Adds regression tests mirroring Mistral's existing coverage: one parametrized non-streaming test per provider covering all three exception classes, plus a streaming-path test per provider. Full anthropic and gemini unit test suites pass locally (162 and 154 tests respectively, no regressions). * Address review: classify 403 as auth, wrap non-APIError failures, cover mid-stream errors - Map HTTP 403 (Anthropic PermissionDeniedError / Gemini ClientError) to ChatClientInvalidAuthException, matching Mistral's 401/403 handling. - Replace the provider-specific except chain with a catch-all that routes every non-framework exception through a _wrap_*_error helper, so transport failures and credential-refresh errors no longer escape ChatClientException. - Re-raise AgentFrameworkException (e.g. ContentError) untouched. - Extend tests to cover 403, a non-APIError fallback, and a failure raised partway through iterating the stream (generator yields then raises). --- .../agent_framework_anthropic/_chat_client.py | 47 +++++++++-- .../anthropic/tests/test_anthropic_client.py | 81 +++++++++++++++++++ .../agent_framework_gemini/_chat_client.py | 55 +++++++++++-- .../gemini/tests/test_gemini_client.py | 60 ++++++++++++++ 4 files changed, 230 insertions(+), 13 deletions(-) diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index f3fab4ca26f..7b0cf4eb792 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -32,8 +32,18 @@ from agent_framework._telemetry import get_user_agent, mark_feature_used from agent_framework._tools import SHELL_TOOL_KIND_VALUE, normalize_tools from agent_framework._types import _get_data_bytes_as_str # type: ignore +from agent_framework.exceptions import ( + AgentFrameworkException, + ChatClientException, + ChatClientInvalidAuthException, + ChatClientInvalidRequestException, +) from agent_framework.observability import ChatTelemetryLayer +from anthropic import APIError as AnthropicAPIError from anthropic import AsyncAnthropic, AsyncAnthropicFoundry +from anthropic import AuthenticationError as AnthropicAuthenticationError +from anthropic import BadRequestError as AnthropicBadRequestError +from anthropic import PermissionDeniedError as AnthropicPermissionDeniedError from anthropic.lib.bedrock import AsyncAnthropicBedrock from anthropic.lib.vertex import AsyncAnthropicVertex from anthropic.types.beta import ( @@ -88,6 +98,23 @@ AnthropicAsyncClient = AsyncAnthropic | AsyncAnthropicBedrock | AsyncAnthropicFoundry | AsyncAnthropicVertex +def _wrap_anthropic_error(ex: Exception) -> ChatClientException: + """Translate a raw anthropic-sdk failure into the framework's ChatClientException hierarchy. + + ``APIError`` instances are classified by HTTP status (401/403 -> auth, other 4xx -> + invalid request), matching the Mistral client. Anything else - connection errors, + timeouts, unexpected SDK exceptions - is still wrapped as a generic ``ChatClientException`` + so callers catching that base type never see a raw provider exception leak through. + """ + if isinstance(ex, AnthropicAPIError): + status = getattr(ex, "status_code", None) + if isinstance(ex, (AnthropicAuthenticationError, AnthropicPermissionDeniedError)) or status in (401, 403): + return ChatClientInvalidAuthException(f"Anthropic authentication failed: {ex}", inner_exception=ex) + if isinstance(ex, AnthropicBadRequestError) or (isinstance(status, int) and 400 <= status < 500): + return ChatClientInvalidRequestException(f"Invalid Anthropic request: {ex}", inner_exception=ex) + return ChatClientException(f"Anthropic chat request failed: {ex}", inner_exception=ex) + + # region Anthropic Chat Options TypedDict @@ -566,17 +593,27 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]: # accumulator to _process_stream_event to emit increments instead. emitted_usage: dict[str, int] = {} mark_feature_used(FeatureIndex.ANTHROPIC) - async for chunk in await self.anthropic_client.beta.messages.create(**run_options, stream=True): - parsed_chunk = self._process_stream_event(chunk, emitted_usage) - if parsed_chunk: - yield parsed_chunk + try: + async for chunk in await self.anthropic_client.beta.messages.create(**run_options, stream=True): + parsed_chunk = self._process_stream_event(chunk, emitted_usage) + if parsed_chunk: + yield parsed_chunk + except AgentFrameworkException: + raise + except Exception as ex: + raise _wrap_anthropic_error(ex) from ex return self._build_response_stream(_stream(), response_format=options.get("response_format")) # Non-streaming mode async def _get_response() -> ChatResponse: mark_feature_used(FeatureIndex.ANTHROPIC) - message = await self.anthropic_client.beta.messages.create(**run_options, stream=False) + try: + message = await self.anthropic_client.beta.messages.create(**run_options, stream=False) + except AgentFrameworkException: + raise + except Exception as ex: + raise _wrap_anthropic_error(ex) from ex return self._process_message(message, options) return _get_response() diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index d0b9a4a8f1a..bd9eba7f16b 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -5,6 +5,8 @@ from typing import Annotated, Any, cast from unittest.mock import MagicMock, patch +import anthropic as anthropic_sdk +import httpx import pytest from agent_framework import ( Agent, @@ -23,6 +25,11 @@ ) from agent_framework._settings import load_settings from agent_framework._tools import SHELL_TOOL_KIND_VALUE +from agent_framework.exceptions import ( + ChatClientException, + ChatClientInvalidAuthException, + ChatClientInvalidRequestException, +) from agent_framework.observability import ChatTelemetryLayer from anthropic.types.beta import ( BetaMessage, @@ -1870,6 +1877,80 @@ async def mock_stream(): assert mock_anthropic_client.beta.messages.create.call_args.kwargs["stream"] is True +def _anthropic_status_error( + error_cls: type[anthropic_sdk.APIStatusError], status_code: int, message: str +) -> anthropic_sdk.APIStatusError: + request = httpx.Request("POST", "https://api.anthropic.com/v1/messages") + response = httpx.Response(status_code, request=request, json={"error": {"message": message}}) + return error_cls(message, response=response, body={"error": {"message": message}}) + + +@pytest.mark.parametrize( + ("sdk_exception", "expected_exception"), + [ + (_anthropic_status_error(anthropic_sdk.AuthenticationError, 401, "boom"), ChatClientInvalidAuthException), + (_anthropic_status_error(anthropic_sdk.PermissionDeniedError, 403, "boom"), ChatClientInvalidAuthException), + (_anthropic_status_error(anthropic_sdk.BadRequestError, 400, "boom"), ChatClientInvalidRequestException), + (_anthropic_status_error(anthropic_sdk.InternalServerError, 500, "boom"), ChatClientException), + # Not an anthropic APIError at all (connection reset, timeout, unexpected SDK bug): + # must still be wrapped so ``except ChatClientException`` callers never see it raw. + (RuntimeError("connection reset"), ChatClientException), + ], +) +async def test_inner_get_response_wraps_sdk_errors( + mock_anthropic_client: MagicMock, + sdk_exception: Exception, + expected_exception: type[Exception], +) -> None: + """Non-streaming _inner_get_response must translate raw Anthropic SDK errors into + the framework's ChatClientException hierarchy, matching every other provider + (OpenAI, Mistral, Ollama, Bedrock).""" + client = create_test_anthropic_client(mock_anthropic_client) + mock_anthropic_client.beta.messages.create.side_effect = sdk_exception + + messages = [Message(role="user", contents=["Hi"])] + chat_options = ChatOptions(max_tokens=10) + + with pytest.raises(expected_exception, match="Anthropic"): + await client._inner_get_response( # type: ignore[attr-defined] + messages=messages, options=chat_options + ) + + +async def test_inner_get_response_streaming_wraps_sdk_errors(mock_anthropic_client: MagicMock) -> None: + """Streaming _inner_get_response must translate raw Anthropic SDK errors into the + framework's ChatClientException hierarchy too, both when the create() call fails and + when the failure happens partway through iterating the stream.""" + client = create_test_anthropic_client(mock_anthropic_client) + messages = [Message(role="user", contents=["Hi"])] + chat_options = ChatOptions(max_tokens=10) + + # 1. Failure raised by the create() call itself. + mock_anthropic_client.beta.messages.create.side_effect = _anthropic_status_error( + anthropic_sdk.AuthenticationError, 401, "invalid api key" + ) + with pytest.raises(ChatClientInvalidAuthException, match="Anthropic"): + async for _ in client._inner_get_response( # type: ignore[attr-defined] # ty: ignore[not-iterable] + messages=messages, options=chat_options, stream=True + ): + pass + + # 2. Failure raised mid-stream, after at least one event has been yielded. + async def _raise_after_first_event() -> Any: + event = MagicMock() + event.type = "message_stop" + yield event + raise _anthropic_status_error(anthropic_sdk.PermissionDeniedError, 403, "permission denied") + + mock_anthropic_client.beta.messages.create.side_effect = None + mock_anthropic_client.beta.messages.create.return_value = _raise_after_first_event() + with pytest.raises(ChatClientInvalidAuthException, match="Anthropic"): + async for _ in client._inner_get_response( # type: ignore[attr-defined] # ty: ignore[not-iterable] + messages=messages, options=chat_options, stream=True + ): + pass + + def test_process_stream_event_message_start_sets_assistant_role(mock_anthropic_client: MagicMock) -> None: """Test that message_start streaming event sets role='assistant'. diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index 28d54de8a4a..17b2feafdb6 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -33,11 +33,18 @@ from agent_framework._settings import SecretString, load_settings from agent_framework._telemetry import get_user_agent, mark_feature_used from agent_framework._types import _get_data_bytes # type: ignore[reportPrivateUsage] -from agent_framework.exceptions import ContentError +from agent_framework.exceptions import ( + AgentFrameworkException, + ChatClientException, + ChatClientInvalidAuthException, + ChatClientInvalidRequestException, + ContentError, +) from agent_framework.observability import ChatTelemetryLayer from google import genai from google.auth.credentials import Credentials from google.genai import types +from google.genai.errors import APIError as GenAIAPIError from pydantic import BaseModel from ._feature_usage import FeatureIndex @@ -71,6 +78,24 @@ ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) +def _wrap_gemini_error(ex: Exception) -> ChatClientException: + """Translate a raw google-genai failure into the framework's ChatClientException hierarchy. + + google-genai ``APIError`` instances are classified by HTTP status (401/403 -> auth, other + 4xx -> invalid request), matching the Mistral client. Anything else - transport errors, + Vertex credential-refresh failures, unexpected SDK exceptions - is still wrapped as a + generic ``ChatClientException`` so callers catching that base type never see a raw + provider exception leak through. + """ + if isinstance(ex, GenAIAPIError): + code = getattr(ex, "code", None) + if code in (401, 403): + return ChatClientInvalidAuthException(f"Gemini authentication failed: {ex}", inner_exception=ex) + if isinstance(code, int) and 400 <= code < 500: + return ChatClientInvalidRequestException(f"Invalid Gemini request: {ex}", inner_exception=ex) + return ChatClientException(f"Gemini chat request failed: {ex}", inner_exception=ex) + + # region Options & Settings @@ -559,12 +584,17 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]: Callable[..., Awaitable[AsyncIterable[types.GenerateContentResponse]]], cast(Any, self._genai_client.aio.models).generate_content_stream, ) - async for chunk in await generate_content_stream( - model=model, - contents=contents, - config=config, - ): - yield self._process_chunk(chunk) + try: + async for chunk in await generate_content_stream( + model=model, + contents=contents, + config=config, + ): + yield self._process_chunk(chunk) + except AgentFrameworkException: + raise + except Exception as ex: + raise _wrap_gemini_error(ex) from ex return self._build_response_stream(_stream(), response_format=options.get("response_format")) @@ -572,7 +602,16 @@ async def _get_response() -> ChatResponse: validated = await self._validate_options(options) model, contents, config = self._prepare_request(messages, validated) mark_feature_used(FeatureIndex.GEMINI) - raw = await self._genai_client.aio.models.generate_content(model=model, contents=contents, config=config) # type: ignore[arg-type] + generate_content = cast( + Callable[..., Awaitable[types.GenerateContentResponse]], + cast(Any, self._genai_client.aio.models).generate_content, + ) + try: + raw = await generate_content(model=model, contents=contents, config=config) + except AgentFrameworkException: + raise + except Exception as ex: + raise _wrap_gemini_error(ex) from ex return self._process_generate_response(raw, response_format=validated.get("response_format")) return _get_response() diff --git a/python/packages/gemini/tests/test_gemini_client.py b/python/packages/gemini/tests/test_gemini_client.py index 1b5d87b749c..74b1687e1d3 100644 --- a/python/packages/gemini/tests/test_gemini_client.py +++ b/python/packages/gemini/tests/test_gemini_client.py @@ -12,6 +12,12 @@ import pytest from agent_framework import Agent, Content, FunctionTool, Message +from agent_framework.exceptions import ( + ChatClientException, + ChatClientInvalidAuthException, + ChatClientInvalidRequestException, +) +from google.genai import errors as genai_errors from google.genai import types from pydantic import BaseModel from typing_extensions import NotRequired, TypedDict @@ -378,6 +384,60 @@ async def test_get_response_returns_text() -> None: assert response.messages[0].text == "Hello!" +@pytest.mark.parametrize( + ("sdk_exception", "expected_exception"), + [ + (genai_errors.ClientError(401, {"error": {"message": "invalid api key"}}), ChatClientInvalidAuthException), + (genai_errors.ClientError(403, {"error": {"message": "permission denied"}}), ChatClientInvalidAuthException), + (genai_errors.ClientError(400, {"error": {"message": "bad request"}}), ChatClientInvalidRequestException), + (genai_errors.ServerError(500, {"error": {"message": "server error"}}), ChatClientException), + # Not a google-genai APIError at all (transport failure, credential refresh, ...): + # must still be wrapped so ``except ChatClientException`` callers never see it raw. + (RuntimeError("connection reset"), ChatClientException), + ], +) +async def test_get_response_wraps_sdk_errors( + sdk_exception: Exception, expected_exception: type[Exception] +) -> None: + """Non-streaming get_response must translate raw google-genai SDK errors into the + framework's ChatClientException hierarchy, matching every other provider + (OpenAI, Anthropic, Mistral, Ollama, Bedrock).""" + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(side_effect=sdk_exception) + + with pytest.raises(expected_exception, match="Gemini"): + await client.get_response(messages=[Message(role="user", contents=[Content.from_text("Hi")])]) + + +async def test_get_response_streaming_wraps_sdk_errors() -> None: + """Streaming get_response must translate raw google-genai SDK errors into the + framework's ChatClientException hierarchy too, both when the call itself fails + and when the failure happens partway through iterating the stream.""" + # 1. Failure raised by the generate_content_stream call itself. + client, mock = _make_gemini_client() + mock.aio.models.generate_content_stream = AsyncMock( + side_effect=genai_errors.ClientError(401, {"error": {"message": "invalid api key"}}) + ) + with pytest.raises(ChatClientInvalidAuthException, match="Gemini"): + async for _ in client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], stream=True + ): + pass + + # 2. Failure raised mid-stream, after at least one chunk has been yielded. + async def _raise_after_first_chunk(**_: Any): + yield _make_response([_make_part(text="partial")]) + raise genai_errors.ClientError(403, {"error": {"message": "permission denied"}}) + + client, mock = _make_gemini_client() + mock.aio.models.generate_content_stream = AsyncMock(return_value=_raise_after_first_chunk()) + with pytest.raises(ChatClientInvalidAuthException, match="Gemini"): + async for _ in client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], stream=True + ): + pass + + async def test_get_response_model_from_response() -> None: """Populates ChatResponse.model from the model_version field in the API response.""" client, mock = _make_gemini_client() From 1a5b13f633028a560be40e2e8ea802630ed5aa48 Mon Sep 17 00:00:00 2001 From: NekoPunch <95899648+orangeCatDeveloper@users.noreply.github.com> Date: Fri, 4 Sep 2026 06:26:42 +0000 Subject: [PATCH 11/85] Python: add Magentic custom manager prompts sample (#7876) * Python: add Magentic custom manager prompts sample * docs(samples): clarify prompt placeholders --- .../03-workflows/orchestrations/README.md | 1 + .../orchestrations/magentic_custom_prompts.py | 214 ++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 python/samples/03-workflows/orchestrations/magentic_custom_prompts.py diff --git a/python/samples/03-workflows/orchestrations/README.md b/python/samples/03-workflows/orchestrations/README.md index eb7d8d477f1..9c18f947bf9 100644 --- a/python/samples/03-workflows/orchestrations/README.md +++ b/python/samples/03-workflows/orchestrations/README.md @@ -71,6 +71,7 @@ from agent_framework.orchestrations import ( | Magentic Workflow | [magentic.py](./magentic.py) | Orchestrate multiple agents with a Magentic manager and streaming | | Magentic + Human Plan Review | [magentic_human_plan_review.py](./magentic_human_plan_review.py) | Human reviews or updates the plan before execution | | Magentic + Checkpoint Resume | [magentic_checkpoint.py](./magentic_checkpoint.py) | Resume Magentic orchestration from saved checkpoints | +| Magentic + Custom Manager Prompts | [magentic_custom_prompts.py](./magentic_custom_prompts.py) | Override the manager's planning, ledger, and final answer prompts | | Magentic Orchestration as Agent | [magentic_workflow_as_agent.py](../agents/magentic_workflow_as_agent.py) | Build a MagenticBuilder workflow and reuse it as an agent | ## Tips diff --git a/python/samples/03-workflows/orchestrations/magentic_custom_prompts.py b/python/samples/03-workflows/orchestrations/magentic_custom_prompts.py new file mode 100644 index 00000000000..1b3784ae460 --- /dev/null +++ b/python/samples/03-workflows/orchestrations/magentic_custom_prompts.py @@ -0,0 +1,214 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os +from typing import cast + +from agent_framework import Agent, AgentResponseUpdate, Message, WorkflowEvent +from agent_framework.foundry import FoundryChatClient +from agent_framework.orchestrations import MagenticBuilder +from agent_framework_orchestrations import MagenticOrchestrator +from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +""" +Sample: Magentic Orchestration with Custom Manager Prompts + +The `StandardMagenticManager` drives planning, replanning, progress tracking, and the final +answer with a set of built-in prompts. Every one of them can be replaced through +`MagenticBuilder`, which is useful when the orchestration has to follow a house style, a +domain vocabulary, or a fixed report format. + +Overridable prompts and the placeholders available to each one: + +| Builder argument | Placeholders | Used for | +| ---------------------------------- | ---------------------------------- | --------------------------------- | +| `task_ledger_facts_prompt` | `{task}` | Initial fact sheet | +| `task_ledger_plan_prompt` | `{team}` | Initial plan | +| `task_ledger_full_prompt` | `{task}`, `{team}`, `{facts}`, `{plan}` | Combined ledger shown to the team | +| `task_ledger_facts_update_prompt` | `{task}`, `{old_facts}` | Fact sheet refresh on replan | +| `task_ledger_plan_update_prompt` | `{team}` | New plan on replan | +| `progress_ledger_prompt` | `{task}`, `{team}`, `{names}` | Per-round progress decision | +| `final_answer_prompt` | `{task}` | Final synthesized answer | + +A prompt is formatted with `str.format`, so any literal brace in a custom prompt must be +doubled (`{{`, `}}`). An override may omit any available placeholder, but any placeholder it +uses must have a name listed above. `progress_ledger_prompt` is the one override to treat with +care: its response is parsed as JSON, so a replacement must keep the same schema as the +built-in prompt. This sample leaves it at the default and overrides the six prompts that shape +the ledger and the final report. Note that the replan prompts have to be overridden alongside +the initial ones: a stall makes the manager rebuild the fact sheet and the plan, and leaving +the replan prompts at their defaults would silently drop the custom format mid-run. + +Prerequisites: +- FOUNDRY_PROJECT_ENDPOINT must be your Microsoft Foundry Agent Service (V2) project endpoint. +- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name. +- Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. +""" + +load_dotenv() + +FACTS_PROMPT = """You are the planning lead of an incident review board. + +Request under review: + +{task} + +Produce a fact sheet using exactly these headings, and nothing else: + + 1. CONFIRMED SIGNALS + 2. SIGNALS TO COLLECT + 3. SIGNALS TO DERIVE + 4. WORKING HYPOTHESES + +Every entry must be a single line starting with "- ". Do not propose next steps yet. +""" + +PLAN_PROMPT = """The review board is staffed as follows: + +{team} + +Write the investigation plan as numbered steps. Each step must be one line in the form: + + . [] -> + +Use at most five steps and only involve a team member when their expertise is required. +""" + +FACTS_UPDATE_PROMPT = """The investigation has stalled on this request: + +{task} + +Rewrite the fact sheet below with everything learned since it was written, keeping the same +four headings and the same one-line "- " entries. Move confirmed items out of the hypothesis +section and add at least one new working hypothesis with its reasoning. + +Previous fact sheet: + +{old_facts} +""" + +PLAN_UPDATE_PROMPT = """State in one sentence why the previous plan stalled, then write a new +investigation plan for this board: + +{team} + +Keep the numbered one-line step format: + + . [] -> + +Use at most five steps and make each one avoid the failure you just named. +""" + +FULL_LEDGER_PROMPT = """INCIDENT REVIEW BRIEF +===================== + +Request: +{task} + +Board: +{team} + +Fact sheet: +{facts} + +Investigation plan: +{plan} +""" + +FINAL_ANSWER_PROMPT = """The investigation of the following request is complete: + +{task} + +Write the closing report for the incident review board with these sections: + + SUMMARY - two sentences, no jargon. + FINDINGS - bullet list, each with the evidence it rests on. + RECOMMENDATION - a single actionable sentence. + +Address the reader directly and do not mention the investigation process itself. +""" + + +async def main() -> None: + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AzureCliCredential(), + ) + + log_analyst = Agent( + name="LogAnalyst", + description="Reads service logs and metrics to reconstruct what happened during an incident", + instructions=( + "You are a log analyst. Reconstruct incident timelines from the evidence you are given " + "and state plainly when evidence is missing." + ), + client=client, + ) + + reliability_engineer = Agent( + name="ReliabilityEngineer", + description="Explains failure modes and proposes mitigations for distributed systems", + instructions="You are a reliability engineer. Explain likely failure modes and propose mitigations.", + client=client, + ) + + manager_agent = Agent( + name="MagenticManager", + description="Orchestrator that runs the incident review board", + instructions="You coordinate a team to complete complex tasks efficiently.", + client=client, + ) + + workflow = MagenticBuilder( + participants=[log_analyst, reliability_engineer], + intermediate_output_from=[log_analyst, reliability_engineer], + manager_agent=manager_agent, + task_ledger_facts_prompt=FACTS_PROMPT, + task_ledger_plan_prompt=PLAN_PROMPT, + task_ledger_full_prompt=FULL_LEDGER_PROMPT, + task_ledger_facts_update_prompt=FACTS_UPDATE_PROMPT, + task_ledger_plan_update_prompt=PLAN_UPDATE_PROMPT, + final_answer_prompt=FINAL_ANSWER_PROMPT, + max_round_count=8, + max_stall_count=2, + ).build() + + task = ( + "A checkout service returned HTTP 503 for 12 minutes after a deployment. Error rates spiked " + "only on the two pods that were rescheduled onto a new node pool, and the database connection " + "pool reported saturation for the same window. Determine the most likely root cause and how to " + "prevent a recurrence." + ) + + print(f"\nTask: {task}\n") + + last_message_id: str | None = None + output_event: WorkflowEvent | None = None + async for event in workflow.run(task, stream=True): + if event.type in ("intermediate", "output"): + if event.executor_id == MagenticOrchestrator.MANAGER_NAME: + output_event = event + else: + update = cast(AgentResponseUpdate, event.data) + if update.message_id != last_message_id: + if last_message_id is not None: + print("\n") + print(f"- {event.executor_id}:", end=" ", flush=True) + last_message_id = update.message_id + print(update, end="", flush=True) + + elif event.type == "magentic_orchestrator" and isinstance(event.data.content, Message): + # The task ledger rendered here follows FULL_LEDGER_PROMPT rather than the built-in layout. + print(f"\n[{event.data.event_type.name}]\n{event.data.content.text}") + + if not output_event: + raise RuntimeError("Workflow did not produce a final output event.") + + print("\n\nFinal report (shaped by FINAL_ANSWER_PROMPT):") + print(cast(AgentResponseUpdate, output_event.data).text) + + +if __name__ == "__main__": + asyncio.run(main()) From 7f20b32cc47cd315ba254793ff68ff07d22bfec1 Mon Sep 17 00:00:00 2001 From: Dineshsuriya D <43177361+droideronline@users.noreply.github.com> Date: Fri, 4 Sep 2026 06:32:12 +0000 Subject: [PATCH 12/85] Python: Fix checkpoint pickling of runtime raw representations (#8023) * Fix checkpoint pickling of runtime raw representations * Fix checkpoint serialization review feedback * Address checkpoint serialization review follow-up * Fix package check private imports * Fix pickle state typing --------- Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> --- .../core/agent_framework/_serialization.py | 56 ++++++++++++++++++- .../packages/core/agent_framework/_types.py | 25 ++++++++- .../tests/core/test_serializable_mixin.py | 53 ++++++++++++++++++ python/packages/core/tests/core/test_types.py | 23 ++++++++ .../tests/workflow/test_agent_executor.py | 19 +++++++ 5 files changed, 174 insertions(+), 2 deletions(-) diff --git a/python/packages/core/agent_framework/_serialization.py b/python/packages/core/agent_framework/_serialization.py index 384fa05a5be..fc383ded5f1 100644 --- a/python/packages/core/agent_framework/_serialization.py +++ b/python/packages/core/agent_framework/_serialization.py @@ -151,6 +151,43 @@ def _is_serialization_protocol(value: Any) -> TypeGuard[SerializationProtocol]: return callable(getattr(value, "to_dict", None)) and callable(getattr(value, "from_dict", None)) +def _iter_instance_fields(instance: Any) -> dict[str, Any]: + """Return attributes stored in ``__dict__`` or slots.""" + fields = dict(getattr(instance, "__dict__", {})) + for cls in type(instance).__mro__: + slots = cls.__dict__.get("__slots__", ()) + if isinstance(slots, str): + slots = (slots,) + for field_name in slots: + if field_name not in {"__dict__", "__weakref__"} and hasattr(instance, field_name): + fields[field_name] = getattr(instance, field_name) + return fields + + +def get_pickle_state(instance: Any, omitted_fields: set[str]) -> dict[str, Any]: + """Build pickle state while omitting runtime-only fields.""" + state = _iter_instance_fields(instance) + for field_name in omitted_fields: + state.pop(field_name, None) + return state + + +def restore_pickle_state( + instance: Any, + state: dict[str, Any] | tuple[dict[str, Any], dict[str, Any]], + omitted_fields: set[str], +) -> None: + """Restore dict- and slot-backed pickle state.""" + if isinstance(state, tuple): + dict_state, slot_state = state + state = {**dict_state, **slot_state} + for field_name, value in state.items(): + object.__setattr__(instance, field_name, value) + for field_name in omitted_fields: + if field_name in _iter_instance_fields(instance) or hasattr(instance, "__dict__"): + object.__setattr__(instance, field_name, None) + + class SerializationMixin: """Mixin class providing comprehensive serialization and deserialization capabilities. @@ -284,6 +321,15 @@ def __init__(self, **kwargs): DEFAULT_EXCLUDE: ClassVar[set[str]] = set() INJECTABLE: ClassVar[set[str]] = set() _SHALLOW_COPY_FIELDS: ClassVar[set[str]] = {"raw_representation"} + _PICKLE_OMIT_FIELDS: ClassVar[set[str]] = {"raw_representation"} + + def __copy__(self) -> SerializationMixin: + """Create a shallow copy without invoking pickle state hooks.""" + cls = type(self) + result = cls.__new__(cls) + for field_name, value in _iter_instance_fields(self).items(): + object.__setattr__(result, field_name, value) + return result def __deepcopy__(self, memo: dict[int, Any]) -> SerializationMixin: """Create a deep copy, preserving ``_SHALLOW_COPY_FIELDS`` by reference. @@ -296,13 +342,21 @@ def __deepcopy__(self, memo: dict[int, Any]) -> SerializationMixin: cls = type(self) result = cls.__new__(cls) memo[id(self)] = result - for k, v in self.__dict__.items(): + for k, v in _iter_instance_fields(self).items(): if k in cls._SHALLOW_COPY_FIELDS: object.__setattr__(result, k, v) else: object.__setattr__(result, k, copy.deepcopy(v, memo)) return result + def __getstate__(self) -> dict[str, Any]: + """Return pickle state without runtime-only shallow-copy fields.""" + return get_pickle_state(self, self._PICKLE_OMIT_FIELDS) + + def __setstate__(self, state: dict[str, Any] | tuple[dict[str, Any], dict[str, Any]]) -> None: + """Restore pickle state and reset runtime-only shallow-copy fields.""" + restore_pickle_state(self, state, self._PICKLE_OMIT_FIELDS) + def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]: """Convert the instance and any nested objects to a dictionary. diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index 9134dc6a046..e4fadb00a37 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -30,7 +30,7 @@ from typing_extensions import TypedDict from ._feature_stage import ExperimentalFeature, experimental -from ._serialization import SerializationMixin +from ._serialization import SerializationMixin, get_pickle_state, restore_pickle_state from .exceptions import AdditionItemMismatch, ContentError if sys.version_info >= (3, 13): @@ -483,6 +483,7 @@ class Content: """ _SHALLOW_COPY_FIELDS: ClassVar[set[str]] = {"raw_representation"} + _PICKLE_OMIT_FIELDS: ClassVar[set[str]] = {"raw_representation"} def __init__( self, @@ -610,6 +611,28 @@ def __deepcopy__(self, memo: dict[int, Any]) -> Content: object.__setattr__(result, k, deepcopy(v, memo)) return result + def __copy__(self) -> Content: + """Create a shallow copy while preserving provider runtime fields.""" + cls = type(self) + result = cls.__new__(cls) + for field_name, value in self.__dict__.items(): + object.__setattr__(result, field_name, value) + return result + + def __getstate__(self) -> dict[str, Any]: + """Return pickle state without runtime-only shallow-copy fields.""" + state = get_pickle_state(self, self._PICKLE_OMIT_FIELDS) + if self.annotations is not None: + state["annotations"] = [ + {key: value for key, value in annotation.items() if key != "raw_representation"} + for annotation in self.annotations + ] + return state + + def __setstate__(self, state: dict[str, Any] | tuple[dict[str, Any], dict[str, Any]]) -> None: + """Restore pickle state and reset runtime-only shallow-copy fields.""" + restore_pickle_state(self, state, self._PICKLE_OMIT_FIELDS) + @classmethod def from_text( cls: type[ContentT], diff --git a/python/packages/core/tests/core/test_serializable_mixin.py b/python/packages/core/tests/core/test_serializable_mixin.py index 03853e83868..d5b14cb0015 100644 --- a/python/packages/core/tests/core/test_serializable_mixin.py +++ b/python/packages/core/tests/core/test_serializable_mixin.py @@ -572,6 +572,59 @@ def __init__(self, items: list, opaque: Any = None, additional_properties: dict assert cloned.items is not obj.items assert cloned.items == ["a"] + def test_shallow_copy_preserves_pickle_omitted_fields(self): + """Shallow copies retain runtime fields that pickle omits.""" + + class TestClass(SerializationMixin): + def __init__(self, raw_representation: Any): + self.raw_representation = raw_representation + + raw = object() + cloned = copy.copy(TestClass(raw)) + + assert cloned.raw_representation is raw + + def test_pickle_restores_slot_fields(self): + """Pickle state should include fields declared in slots.""" + class TestClass(SerializationMixin): + __slots__ = ("value",) + + def __init__(self, value: str): + self.value = value + + original = TestClass("value") + restored = TestClass.__new__(TestClass) + restored.__setstate__(original.__getstate__()) + + assert restored.value == "value" + + def test_pickle_restores_legacy_tuple_state(self): + """Pickle restoration should accept the legacy dict-and-slots tuple.""" + + class TestClass(SerializationMixin): + __slots__ = ("value",) + + def __init__(self): + self.value = "new" + + restored = TestClass.__new__(TestClass) + restored.__setstate__(({"other": "dict"}, {"value": "legacy"})) + + assert restored.value == "legacy" + + def test_pickle_omission_is_separate_from_shallow_copy_policy(self): + """Fields shallow-copied by default remain persistent unless explicitly omitted.""" + class TestClass(SerializationMixin): + _PICKLE_OMIT_FIELDS = set() + + def __init__(self, raw_representation: Any): + self.raw_representation = raw_representation + + raw = {"provider": "value"} + state = TestClass(raw).__getstate__() + + assert state["raw_representation"] == raw + def test_dependency_dict_merge_does_not_mutate_input(self): """Test that dict dependency merging does not mutate the caller's input dictionary.""" diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index a4d5957f587..e936da808af 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -2733,6 +2733,29 @@ def test_content_deepcopy_discards_raw_representation(caplog: pytest.LogCaptureF assert caplog.messages == ["Discarding field 'raw_representation' while deep-copying Content."] +def test_content_pickle_discards_nested_annotation_raw_representation() -> None: + """Pickle should omit provider objects stored on annotations.""" + import pickle + + raw = object() + annotation: Annotation = {"type": "citation", "url": "https://example.com", "raw_representation": raw} + content = Content.from_text("hello", annotations=[annotation]) + + restored = pickle.loads(pickle.dumps(content)) + + assert restored.annotations == [{"type": "citation", "url": "https://example.com"}] + + +def test_content_shallow_copy_preserves_raw_representation() -> None: + """Shallow copies of Content retain provider runtime fields.""" + import copy + + raw = _NonCopyableRaw() + cloned = copy.copy(Content.from_text("hello", raw_representation=raw)) + + assert cloned.raw_representation is raw + + def test_message_deepcopy_preserves_raw_representation(): """Test that deepcopy of Message keeps raw_representation by reference.""" import copy diff --git a/python/packages/core/tests/workflow/test_agent_executor.py b/python/packages/core/tests/workflow/test_agent_executor.py index ccb1e9425bf..2cc2ed2ce6e 100644 --- a/python/packages/core/tests/workflow/test_agent_executor.py +++ b/python/packages/core/tests/workflow/test_agent_executor.py @@ -1,5 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. +import pickle + from collections.abc import AsyncIterable, Awaitable from typing import Any, Literal, overload @@ -336,6 +338,9 @@ class _NonCopyableRaw: def __deepcopy__(self, memo: dict) -> Any: raise TypeError("Cannot deepcopy this object") + def __reduce__(self) -> Any: + raise TypeError("Cannot pickle this object") + class _AgentWithRawRepr(BaseAgent): """Agent that returns responses with a non-copyable raw_representation.""" @@ -387,6 +392,20 @@ async def test_agent_executor_workflow_with_non_copyable_raw_representation() -> assert agent_responses[0].raw_representation is raw +def test_serialization_mixin_omits_non_pickleable_raw_representation() -> None: + """Pickling framework objects should not include runtime-only raw representations.""" + raw = _NonCopyableRaw() + response = AgentResponse( + messages=[Message("assistant", [Content.from_text(text="reply", raw_representation=raw)])], + raw_representation=raw, + ) + + restored = pickle.loads(pickle.dumps(response)) + + assert restored.raw_representation is None + assert restored.messages[0].contents[0].raw_representation is None + + # --------------------------------------------------------------------------- # Context mode tests # --------------------------------------------------------------------------- From 59fb11af441f33341abbf037c8d663520a5cb770 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Fri, 4 Sep 2026 06:57:53 +0000 Subject: [PATCH 13/85] Enable feature issue ownership triage (#8047) * Run ownership triage for feature issues * Cancel stale issue triage after retyping --- .github/workflows/issue-triage.yml | 38 ++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml index af17d8cfcd1..3b6d82f98a3 100644 --- a/.github/workflows/issue-triage.yml +++ b/.github/workflows/issue-triage.yml @@ -19,7 +19,7 @@ concurrency: group: >- issue-triage-${{ github.repository }}-${{ github.event_name == 'workflow_dispatch' && inputs.issue_number - || github.event.issue.type.name == 'Bug' && github.event.issue.number + || github.event_name == 'issues' && github.event.issue.number || github.run_id }} cancel-in-progress: true @@ -39,11 +39,13 @@ jobs: ${{ github.event_name == 'workflow_dispatch' || github.event.issue.type.name == 'Bug' + || github.event.issue.type.name == 'Feature' }} outputs: is_team_member: ${{ steps.check.outputs.is_team_member }} issue_number: ${{ steps.issue.outputs.issue_number }} repo: ${{ steps.issue.outputs.repo }} + issue_type: ${{ steps.issue-type.outputs.issue_type }} steps: - name: Resolve issue metadata id: issue @@ -116,6 +118,36 @@ jobs: core.info(`Author ${author} is not a team member; proceeding with triage.`); } + - name: Resolve native issue type + id: issue-type + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + ISSUE_NUMBER: ${{ steps.issue.outputs.issue_number }} + with: + github-token: ${{ steps.github-auth.outputs.token }} + script: | + const issueNumber = Number(process.env.ISSUE_NUMBER); + const eventType = context.payload.issue?.type?.name; + if (eventType) { + core.setOutput('issue_type', eventType); + return; + } + + const result = await github.graphql(` + query ResolveNativeIssueType($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { + issueType { name } + } + } + } + `, { + owner: context.repo.owner, + repo: context.repo.repo, + number: issueNumber, + }); + core.setOutput('issue_type', result.repository?.issue?.issueType?.name || ''); + triage: runs-on: ubuntu-latest needs: team_check @@ -198,7 +230,7 @@ jobs: echo "Stopping: issue triage preflight did not allow automation." exit 1 - - name: Reproduce reported issue + - name: Triage reported issue if: ${{ steps.spam.outputs.allow_triage == 'true' }} id: repro working-directory: ${{ env.DEVFLOW_PATH }} @@ -211,6 +243,7 @@ jobs: AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }} ISSUE_REPO: ${{ needs.team_check.outputs.repo }} ISSUE_NUMBER: ${{ needs.team_check.outputs.issue_number }} + ISSUE_TYPE: ${{ needs.team_check.outputs.issue_type }} # Model-provider settings for generated repro code. Never enter the # agent prompt; consumed by SDK constructors via os.environ. Azure # OpenAI and Foundry auth via AAD from the azure/login step above. @@ -237,4 +270,5 @@ jobs: uv run python scripts/trigger_issue_repro.py \ --repo "$ISSUE_REPO" \ --issue-number "$ISSUE_NUMBER" \ + --issue-type "$ISSUE_TYPE" \ --github-username "$GITHUB_ACTOR" From 21f386e712ac1a98881c08c0ed9afab6232872fd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 06:58:16 +0000 Subject: [PATCH 14/85] Python: Chore(deps-dev): Bump ruff from 0.16.3 to 0.16.4 in /python (#8036) * Chore(deps-dev): Bump ruff from 0.16.3 to 0.16.4 in /python Bumps [ruff](https://github.com/astral-sh/ruff) from 0.16.3 to 0.16.4. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.16.3...0.16.4) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.16.4 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * Fix Ruff pins across Python workspace Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> Co-authored-by: moonbox3 <35585003+moonbox3@users.noreply.github.com> --- python/packages/lab/pyproject.toml | 2 +- python/pyproject.toml | 2 +- python/uv.lock | 46 +++++++++++++++--------------- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/python/packages/lab/pyproject.toml b/python/packages/lab/pyproject.toml index e88eba29fca..53f885c22e5 100644 --- a/python/packages/lab/pyproject.toml +++ b/python/packages/lab/pyproject.toml @@ -58,7 +58,7 @@ math = [ [dependency-groups] dev = [ "uv==0.12.6", - "ruff==0.16.3", + "ruff==0.16.4", "pytest==9.1.1", "mypy==2.3.1", "pyright==1.1.411", diff --git a/python/pyproject.toml b/python/pyproject.toml index f0a5dab6d90..ec35689bb4b 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -30,7 +30,7 @@ dependencies = [ dev = [ "uv==0.12.6", "flit==4.0.2", - "ruff==0.16.3", + "ruff==0.16.4", "pytest==9.1.1", "pytest-asyncio==1.4.0", "pytest-cov==7.1.0", diff --git a/python/uv.lock b/python/uv.lock index 91736806b4b..b612dd2b739 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -177,7 +177,7 @@ dev = [ { name = "pytest-timeout", specifier = "==2.4.0" }, { name = "pytest-xdist", extras = ["psutil"], specifier = "==3.8.0" }, { name = "rich", specifier = ">=13.7.1,<16.0.0" }, - { name = "ruff", specifier = "==0.16.3" }, + { name = "ruff", specifier = "==0.16.4" }, { name = "tomli", specifier = "==2.4.1" }, { name = "ty", specifier = "==0.0.72" }, { name = "uv", specifier = "==0.12.6" }, @@ -881,7 +881,7 @@ dev = [ { name = "pyright", specifier = "==1.1.411" }, { name = "pytest", specifier = "==9.1.1" }, { name = "rich", specifier = ">=13.7.1,<15.0.0" }, - { name = "ruff", specifier = "==0.16.3" }, + { name = "ruff", specifier = "==0.16.4" }, { name = "tomli", specifier = "==2.4.1" }, { name = "tomli-w", specifier = "==1.2.0" }, { name = "uv", specifier = "==0.12.6" }, @@ -6857,27 +6857,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" }, - { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" }, - { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" }, - { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" }, - { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" }, - { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" }, - { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" }, - { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" }, - { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" }, - { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" }, - { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" }, - { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" }, - { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" }, - { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" }, - { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" }, +version = "0.16.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" }, + { url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" }, + { url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" }, + { url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" }, + { url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" }, + { url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" }, + { url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" }, + { url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687, upload-time = "2026-08-20T17:43:52.281Z" }, + { url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" }, ] [[package]] From 13108ed40aed95514e3fdcce4ef5c65e6b95f168 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 4 Sep 2026 08:10:28 +0000 Subject: [PATCH 15/85] Python: move Foundry eval serialization out of core (#8031) * Python: move Foundry eval serialization out of core Keep provider-neutral EvalItem construction in core while moving the Foundry Evals wire conversion into agent-framework-foundry. Remove the accidental experimental converter export and migrate its tests, sample, and current package documentation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: mark eval item helper experimental Preserve the EVALS feature-stage warning and metadata on the private EvalItem construction helper after removing AgentEvalConverter. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: normalize tools for eval items Apply the standard tool normalization path when constructing EvalItems so callable tools become FunctionTool instances. Simplify the Foundry tool-call sample to use @tool-decorated functions directly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: address Foundry eval review feedback Remove the stale accepted-ADR link from the Foundry Evals module and keep the tool-call sample's decorated tools directly in the agent and eval item definitions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: deprecate AgentEvalConverter compatibly Keep the experimental converter import and static methods available so released Foundry packages remain compatible with their declared core 1.x range. Warn on legacy method use while modern Foundry continues to own active wire serialization. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- python/packages/core/AGENTS.md | 11 + .../core/agent_framework/_evaluation.py | 302 ++++++------ .../core/tests/core/test_evaluation.py | 368 ++++++++++++++ python/packages/foundry/README.md | 26 + .../agent_framework_foundry/_foundry_evals.py | 76 ++- .../foundry/tests/test_foundry_evals.py | 448 +----------------- .../evaluation/foundry_evals/README.md | 19 +- .../evaluate_tool_calls_sample.py | 9 +- 8 files changed, 650 insertions(+), 609 deletions(-) create mode 100644 python/packages/core/tests/core/test_evaluation.py diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index 257782f7c34..288e07a2a6e 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -217,6 +217,17 @@ agent_framework/ hosts remain responsible for authorizing and tenant-scoping access to any shared checkpoint adapter. - **Orchestrators**: `SequentialOrchestrator`, `ConcurrentOrchestrator`, `GroupChatOrchestrator`, `MagenticOrchestrator`, `HandoffOrchestrator` +## Evaluation (`_evaluation.py`) + +- Core owns provider-neutral evaluation types, local evaluators and checks, `EvalItem` construction, and the + `evaluate_agent` / `evaluate_workflow` orchestration functions. +- Provider packages own their service-specific evaluator implementations and wire serialization. Core evaluation + code must not emit a provider's request schema. The deprecated `AgentEvalConverter` remains as a temporary + compatibility shim for released Foundry packages whose declared core range still imports it; new code must not use + the shim. +- Core orchestration builds `EvalItem` instances through private helpers; callers needing manual control construct + the public `EvalItem` directly. + ## Built-in Providers ### OpenAI (`openai/`) diff --git a/python/packages/core/agent_framework/_evaluation.py b/python/packages/core/agent_framework/_evaluation.py index ab71d84a15c..327258b846b 100644 --- a/python/packages/core/agent_framework/_evaluation.py +++ b/python/packages/core/agent_framework/_evaluation.py @@ -39,6 +39,7 @@ import inspect import json import logging +import warnings from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass, field from enum import Enum @@ -54,7 +55,7 @@ ) from ._feature_stage import ExperimentalFeature, experimental -from ._tools import FunctionTool +from ._tools import FunctionTool, normalize_tools from ._types import AgentResponse, Message if TYPE_CHECKING: @@ -726,157 +727,121 @@ async def evaluate( # endregion -# region Converter + +def _warn_agent_eval_converter_deprecated() -> None: + """Warn when the legacy evaluation converter compatibility surface is used.""" + warnings.warn( + "`AgentEvalConverter` is deprecated and will be removed in a future version. " + "Construct `EvalItem` directly or use `evaluate_agent()` / `evaluate_workflow()`; " + "Foundry wire conversion is internal to `agent-framework-foundry`.", + DeprecationWarning, + stacklevel=3, + ) + + +def _convert_legacy_foundry_message(message: Message) -> list[dict[str, Any]]: + """Preserve the legacy Foundry wire conversion for package compatibility.""" + content_items: list[dict[str, Any]] = [] + tool_results: list[dict[str, Any]] = [] + + for content in message.contents or []: + if content.type == "text" and content.text: + content_items.append({"type": "text", "text": content.text}) + elif content.type in ("data", "uri") and content.uri: + image: dict[str, Any] = { + "type": "input_image", + "image_url": content.uri, + } + if content.media_type: + image["detail"] = "auto" + content_items.append(image) + elif content.type == "function_call": + arguments = content.arguments + if isinstance(arguments, str): + try: + arguments = json.loads(arguments) + except (json.JSONDecodeError, TypeError): + arguments = {"_raw_arguments": "[unparseable]"} + content_items.append({ + "type": "tool_call", + "tool_call_id": content.call_id or "", + "name": content.name or "", + "arguments": arguments if arguments is not None else {}, + }) + elif content.type == "function_result": + result = content.result + if isinstance(result, str): + with contextlib.suppress(json.JSONDecodeError, TypeError): + result = json.loads(result) + tool_results.append({ + "call_id": content.call_id or "", + "result": result, + }) + + if tool_results: + return [ + { + "role": "tool", + "tool_call_id": tool_result["call_id"], + "content": [{"type": "tool_result", "tool_result": tool_result["result"]}], + } + for tool_result in tool_results + ] + if content_items: + return [{"role": message.role, "content": content_items}] + return [ + { + "role": message.role, + "content": [{"type": "text", "text": ""}], + } + ] @experimental(feature_id=ExperimentalFeature.EVALS) class AgentEvalConverter: - """Converts agent-framework types to evaluation format. + """Deprecated compatibility surface for earlier Agent Framework releases. - Handles the type gap between agent-framework's ``Message`` / ``Content`` / - ``FunctionTool`` types and the OpenAI-style agent message schema used by - evaluation providers. All methods are static — no instantiation needed. + New code should construct :class:`EvalItem` directly or use + :func:`evaluate_agent` / :func:`evaluate_workflow`. Foundry-specific wire + serialization is owned by ``agent-framework-foundry``. """ @staticmethod def convert_message(message: Message) -> list[dict[str, Any]]: - """Convert a single ``Message`` to Foundry agent evaluator format. - - Uses typed content lists as required by Foundry evaluators: - - .. code-block:: python - - {"role": "assistant", "content": [{"type": "tool_call", ...}]} - {"role": "user", "content": [{"type": "input_image", ...}]} - - Supported content types: - - * ``text`` → ``{"type": "text", "text": ...}`` - * ``data`` / ``uri`` (images) → ``{"type": "input_image", "image_url": ...}`` - * ``function_call`` → ``{"type": "tool_call", ...}`` - * ``function_result`` → ``{"type": "tool_result", ...}`` - - A single agent-framework ``Message`` with multiple ``function_result`` - contents produces multiple output messages (one per tool result). - - Args: - message: An agent-framework ``Message``. - - Returns: - A list of Foundry-format message dicts. - """ - role = message.role - contents = message.contents or [] - - content_items: list[dict[str, Any]] = [] - tool_results: list[dict[str, Any]] = [] - - for c in contents: - if c.type == "text" and c.text: - content_items.append({"type": "text", "text": c.text}) - elif c.type in ("data", "uri") and c.uri: - # Image / media content → OpenAI input_image format - img: dict[str, Any] = { - "type": "input_image", - "image_url": c.uri, - } - if c.media_type: - img["detail"] = "auto" - content_items.append(img) - elif c.type == "function_call": - args = c.arguments - if isinstance(args, str): - try: - args = json.loads(args) - except (json.JSONDecodeError, TypeError): - # Sanitize to avoid leaking sensitive tool-call arguments - # to external evaluation services. - args = {"_raw_arguments": "[unparseable]"} - tc: dict[str, Any] = { - "type": "tool_call", - "tool_call_id": c.call_id or "", - "name": c.name or "", - } - tc["arguments"] = args if args is not None else {} - content_items.append(tc) - elif c.type == "function_result": - result_val = c.result - if isinstance(result_val, str): - with contextlib.suppress(json.JSONDecodeError, TypeError): - result_val = json.loads(result_val) - tool_results.append({ - "call_id": c.call_id or "", - "result": result_val, - }) - - output: list[dict[str, Any]] = [] - - if tool_results: - for tr in tool_results: - output.append({ - "role": "tool", - "tool_call_id": tr["call_id"], - "content": [{"type": "tool_result", "tool_result": tr["result"]}], - }) - elif content_items: - output.append({"role": role, "content": content_items}) - else: - output.append({ - "role": role, - "content": [{"type": "text", "text": ""}], - }) - - return output + """Convert one message using the legacy Foundry evaluator wire format.""" + _warn_agent_eval_converter_deprecated() + return _convert_legacy_foundry_message(message) @staticmethod def convert_messages(messages: Sequence[Message]) -> list[dict[str, Any]]: - """Convert a sequence of ``Message`` objects to Foundry evaluator format. - - Args: - messages: Agent-framework messages. - - Returns: - A list of Foundry-format message dicts with typed content lists. - """ - result: list[dict[str, Any]] = [] - for msg in messages: - result.extend(AgentEvalConverter.convert_message(msg)) - return result + """Convert messages using the legacy Foundry evaluator wire format.""" + _warn_agent_eval_converter_deprecated() + return [converted for message in messages for converted in _convert_legacy_foundry_message(message)] @staticmethod def extract_tools(agent: Any) -> list[dict[str, Any]]: - """Extract tool definitions from an agent instance. - - Reads ``agent.default_options["tools"]`` and ``agent.mcp_tools`` - and converts each ``FunctionTool`` to ``{name, description, parameters}``. - - Args: - agent: An agent-framework agent instance. - - Returns: - A list of tool definition dicts. - """ + """Extract legacy evaluator tool-definition dictionaries from an agent.""" + _warn_agent_eval_converter_deprecated() tools: list[dict[str, Any]] = [] seen: set[str] = set() raw_tools = getattr(agent, "default_options", {}).get("tools", []) - for t in raw_tools: - if isinstance(t, FunctionTool) and t.name not in seen: + for tool in raw_tools: + if isinstance(tool, FunctionTool) and tool.name not in seen: tools.append({ - "name": t.name, - "description": t.description, - "parameters": t.parameters(), + "name": tool.name, + "description": tool.description, + "parameters": tool.parameters(), }) - seen.add(t.name) - # Include tools from connected MCP servers + seen.add(tool.name) for mcp in getattr(agent, "mcp_tools", []): - for t in getattr(mcp, "functions", []): - if isinstance(t, FunctionTool) and t.name not in seen: + for tool in getattr(mcp, "functions", []): + if isinstance(tool, FunctionTool) and tool.name not in seen: tools.append({ - "name": t.name, - "description": t.description, - "parameters": t.parameters(), + "name": tool.name, + "description": tool.description, + "parameters": tool.parameters(), }) - seen.add(t.name) + seen.add(tool.name) return tools @staticmethod @@ -885,47 +850,46 @@ def to_eval_item( query: str | Sequence[Message], response: AgentResponse[Any], agent: Any | None = None, - tools: Sequence[FunctionTool] | None = None, + tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None, context: str | None = None, ) -> EvalItem: - """Convert a complete agent interaction to an ``EvalItem``. + """Build an ``EvalItem`` through the provider-neutral compatibility path.""" + _warn_agent_eval_converter_deprecated() + return _to_eval_item(query=query, response=response, agent=agent, tools=tools, context=context) - Args: - query: The user query string, or input messages. - response: The agent's response. - agent: Optional agent instance to auto-extract tool definitions. - tools: Explicit tool list (takes precedence over *agent*). - context: Optional context document for groundedness evaluation. - - Returns: - An ``EvalItem`` suitable for passing to any ``Evaluator``. - """ - input_msgs = [Message("user", [query])] if isinstance(query, str) else list(query) - - all_msgs = list(input_msgs) + list(response.messages or []) - - typed_tools: list[FunctionTool] = [] - if tools: - typed_tools = list(tools) - elif agent: - raw_tools = getattr(agent, "default_options", {}).get("tools", []) - typed_tools = [t for t in raw_tools if isinstance(t, FunctionTool)] - # Include tools from connected MCP servers - seen = {t.name for t in typed_tools} - for mcp in getattr(agent, "mcp_tools", []): - for t in getattr(mcp, "functions", []): - if isinstance(t, FunctionTool) and t.name not in seen: - typed_tools.append(t) - seen.add(t.name) - - return EvalItem( - conversation=all_msgs, - tools=typed_tools or None, - context=context, - ) +@experimental(feature_id=ExperimentalFeature.EVALS) +def _to_eval_item( + *, + query: str | Sequence[Message], + response: AgentResponse[Any], + agent: Any | None = None, + tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None, + context: str | None = None, +) -> EvalItem: + """Build a provider-neutral ``EvalItem`` from an agent interaction.""" + input_msgs = [Message("user", [query])] if isinstance(query, str) else list(query) + all_msgs = list(input_msgs) + list(response.messages or []) + + typed_tools: list[FunctionTool] = [] + if tools: + typed_tools = [tool for tool in normalize_tools(tools) if isinstance(tool, FunctionTool)] + elif agent: + raw_tools = getattr(agent, "default_options", {}).get("tools", []) + typed_tools = [tool for tool in normalize_tools(raw_tools) if isinstance(tool, FunctionTool)] + seen = {tool.name for tool in typed_tools} + for mcp in getattr(agent, "mcp_tools", []): + for tool in getattr(mcp, "functions", []): + if isinstance(tool, FunctionTool) and tool.name not in seen: + typed_tools.append(tool) + seen.add(tool.name) + + return EvalItem( + conversation=all_msgs, + tools=typed_tools or None, + context=context, + ) -# endregion # region Workflow extraction helpers @@ -1772,7 +1736,7 @@ async def evaluate_agent( raise ValueError(f"Got {len(query_list)} queries but {len(resp_list)} responses.") for q, r in zip(query_list, resp_list): items.append( - AgentEvalConverter.to_eval_item( + _to_eval_item( query=q, response=r, agent=agent, @@ -1792,7 +1756,7 @@ async def evaluate_agent( for query in queries: response = await agent.run([Message("user", [query])]) items.append( - AgentEvalConverter.to_eval_item( + _to_eval_item( query=query, response=response, agent=agent, @@ -1962,7 +1926,7 @@ async def evaluate_workflow( agent_items_by_id: dict[str, list[EvalItem]] = {} for executor_id, agent_data_list in agents_by_id.items(): agent_items_by_id[executor_id] = [ - AgentEvalConverter.to_eval_item( + _to_eval_item( query=ad["query"], response=ad["response"], agent=ad["agent"], @@ -2051,7 +2015,7 @@ def _build_overall_item( messages=[Message("assistant", [str(final_output)])] # type: ignore[reportUnknownArgumentType] ) - return AgentEvalConverter.to_eval_item(query=query, response=overall_response) + return _to_eval_item(query=query, response=overall_response) def _resolve_evaluators( diff --git a/python/packages/core/tests/core/test_evaluation.py b/python/packages/core/tests/core/test_evaluation.py new file mode 100644 index 00000000000..f32e216bd9b --- /dev/null +++ b/python/packages/core/tests/core/test_evaluation.py @@ -0,0 +1,368 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for provider-neutral evaluation item construction and splitting.""" + +from __future__ import annotations + +from typing import Any, cast +from unittest.mock import MagicMock + +import pytest + +from agent_framework import AgentEvalConverter as ExportedAgentEvalConverter +from agent_framework._evaluation import AgentEvalConverter, ConversationSplit, EvalItem, _to_eval_item +from agent_framework._tools import FunctionTool +from agent_framework._types import AgentResponse, Content, Message + + +class TestAgentEvalConverterCompatibility: + def test_root_export_is_legacy_converter(self) -> None: + assert ExportedAgentEvalConverter is AgentEvalConverter + + def test_convert_messages_preserves_legacy_foundry_wire_format(self) -> None: + messages = [ + Message("user", ["What's the weather?"]), + Message( + "assistant", + [Content.from_function_call(call_id="call_1", name="get_weather", arguments={"city": "Seattle"})], + ), + ] + + with pytest.warns(DeprecationWarning, match="AgentEvalConverter"): + converted = AgentEvalConverter.convert_messages(messages) + + assert converted == [ + {"role": "user", "content": [{"type": "text", "text": "What's the weather?"}]}, + { + "role": "assistant", + "content": [ + { + "type": "tool_call", + "tool_call_id": "call_1", + "name": "get_weather", + "arguments": {"city": "Seattle"}, + } + ], + }, + ] + + def test_to_eval_item_delegates_to_provider_neutral_builder(self) -> None: + response = AgentResponse(messages=[Message("assistant", ["Sunny."])]) + + with pytest.warns(DeprecationWarning, match="AgentEvalConverter"): + item = AgentEvalConverter.to_eval_item(query="Weather?", response=response) + + assert item.query == "Weather?" + assert item.response == "Sunny." + + +class TestToEvalItem: + def test_string_query(self) -> None: + response = AgentResponse(messages=[Message("assistant", ["The weather is sunny."])]) + item = _to_eval_item(query="What's the weather?", response=response) + + assert item.query == "What's the weather?" + assert item.response == "The weather is sunny." + assert [message.role for message in item.conversation] == ["user", "assistant"] + + def test_message_query(self) -> None: + input_messages = [ + Message("system", ["Be helpful."]), + Message("user", ["Hello"]), + ] + response = AgentResponse(messages=[Message("assistant", ["Hi there!"])]) + + item = _to_eval_item(query=input_messages, response=response) + + assert item.query == "Hello" + assert len(item.conversation) == 3 + + def test_with_context(self) -> None: + response = AgentResponse(messages=[Message("assistant", ["Answer."])]) + + item = _to_eval_item( + query="Question?", + response=response, + context="Some reference document.", + ) + + assert item.context == "Some reference document." + + def test_with_explicit_tools(self) -> None: + def search(query: str) -> str: + """Search the web.""" + return f"Results for {query}" + + response = AgentResponse(messages=[Message("assistant", ["Found it."])]) + + item = _to_eval_item(query="Find info", response=response, tools=[search]) + + assert item.tools is not None + assert len(item.tools) == 1 + assert item.tools[0].name == "search" + + def test_with_agent_and_mcp_tools(self) -> None: + agent_tool = FunctionTool(name="calculate", description="Calculate", func=lambda value: str(value)) + mcp_tool = FunctionTool(name="search", description="Search", func=lambda query: query) + agent = MagicMock() + agent.default_options = {"tools": [agent_tool]} + agent.mcp_tools = [MagicMock(functions=[agent_tool, mcp_tool])] + response = AgentResponse(messages=[Message("assistant", ["Done"])]) + + item = _to_eval_item(query="Research this", response=response, agent=agent) + + assert item.tools == [agent_tool, mcp_tool] + + def test_explicit_tools_override_agent(self) -> None: + agent_tool = FunctionTool(name="agent_tool", description="from agent", func=lambda: "") + explicit_tool = FunctionTool(name="explicit_tool", description="explicit", func=lambda: "") + agent = MagicMock() + agent.default_options = {"tools": [agent_tool]} + response = AgentResponse(messages=[Message("assistant", ["Done"])]) + + item = _to_eval_item( + query="Test", + response=response, + agent=agent, + tools=[explicit_tool], + ) + + assert item.tools == [explicit_tool] + + +class TestEvalItemSplitting: + def test_split_messages_format(self) -> None: + tool = FunctionTool(name="test", description="Test", func=lambda: "") + item = _to_eval_item( + query="Q", + response=AgentResponse(messages=[Message("assistant", ["Answer"])]), + tools=[tool], + ) + + query_messages, response_messages = item.split_messages() + + assert [message.role for message in query_messages] == ["user"] + assert [message.role for message in response_messages] == ["assistant"] + assert item.tools == [tool] + + def test_multiturn_preserves_interleaving(self) -> None: + conversation = [ + Message("user", ["What's the weather?"]), + Message("assistant", ["It's sunny in Seattle."]), + Message("user", ["And tomorrow?"]), + Message("assistant", [Content(type="function_call", name="get_forecast")]), + Message("tool", [Content(type="function_result", result="Rain expected")]), + Message("assistant", ["Rain is expected tomorrow."]), + ] + + query_messages, response_messages = EvalItem(conversation=conversation).split_messages() + + assert [message.role for message in query_messages] == ["user", "assistant", "user"] + assert [message.role for message in response_messages] == ["assistant", "tool", "assistant"] + + def test_full_split(self) -> None: + conversation = [ + Message("user", ["What's the weather?"]), + Message("assistant", ["It's 62°F in Seattle."]), + Message("user", ["And tomorrow?"]), + Message("assistant", ["Rain is expected tomorrow."]), + ] + + query_messages, response_messages = EvalItem(conversation=conversation).split_messages( + split=cast(Any, ConversationSplit.FULL) + ) + + assert [message.text for message in query_messages] == ["What's the weather?"] + assert [message.role for message in response_messages] == ["assistant", "user", "assistant"] + + def test_full_split_includes_system_message(self) -> None: + conversation = [ + Message("system", ["You are a weather assistant."]), + Message("user", ["What's the weather?"]), + Message("assistant", ["It's sunny."]), + ] + + query_messages, response_messages = EvalItem(conversation=conversation).split_messages( + split=cast(Any, ConversationSplit.FULL) + ) + + assert [message.role for message in query_messages] == ["system", "user"] + assert [message.role for message in response_messages] == ["assistant"] + + def test_full_split_puts_tool_interactions_in_response(self) -> None: + conversation = [ + Message("user", ["What's the weather?"]), + Message("assistant", [Content(type="function_call", name="get_weather")]), + Message("tool", [Content(type="function_result", result="62°F")]), + Message("assistant", ["It's 62°F."]), + Message("user", ["Thanks!"]), + Message("assistant", ["You're welcome!"]), + ] + + query_messages, response_messages = EvalItem(conversation=conversation).split_messages( + split=cast(Any, ConversationSplit.FULL) + ) + + assert len(query_messages) == 1 + assert len(response_messages) == 5 + + def test_last_turn_is_default(self) -> None: + conversation = [ + Message("user", ["Hello"]), + Message("assistant", ["Hi there"]), + Message("user", ["Bye"]), + Message("assistant", ["Goodbye"]), + ] + item = EvalItem(conversation=conversation) + + default_query, default_response = item.split_messages() + explicit_query, explicit_response = item.split_messages(split=cast(Any, ConversationSplit.LAST_TURN)) + + assert default_query == explicit_query + assert default_response == explicit_response + + def test_per_turn_items(self) -> None: + conversation = [ + Message("user", ["What's the weather?"]), + Message("assistant", ["It's 62°F."]), + Message("user", ["And tomorrow?"]), + Message("assistant", ["Rain expected."]), + ] + + items = EvalItem.per_turn_items(conversation) + + assert len(items) == 2 + assert (items[0].query, items[0].response) == ("What's the weather?", "It's 62°F.") + assert (items[1].query, items[1].response) == ("What's the weather? And tomorrow?", "Rain expected.") + assert [len(item.conversation) for item in items] == [2, 4] + + def test_per_turn_items_preserve_tools(self) -> None: + conversation = [ + Message("user", ["Check weather"]), + Message("assistant", [Content(type="function_call", name="get_weather")]), + Message("tool", [Content(type="function_result", result="sunny")]), + Message("assistant", ["It's sunny."]), + Message("user", ["Thanks"]), + Message("assistant", ["You're welcome!"]), + ] + tool = FunctionTool(name="get_weather", description="Get weather") + + items = EvalItem.per_turn_items(conversation, tools=[tool]) + + assert len(items) == 2 + assert items[0].tools == [tool] + assert items[0].response == "It's sunny." + assert items[1].response == "You're welcome!" + + def test_per_turn_items_without_user_messages(self) -> None: + assert EvalItem.per_turn_items([Message("assistant", ["Hello"])]) == [] + + def test_per_turn_items_single_turn(self) -> None: + items = EvalItem.per_turn_items([ + Message("user", ["Hi"]), + Message("assistant", ["Hello!"]), + ]) + + assert len(items) == 1 + assert (items[0].query, items[0].response) == ("Hi", "Hello!") + + def test_custom_splitter_callable(self) -> None: + conversation = [ + Message("user", ["Remember my name is Alice"]), + Message("assistant", ["Got it, Alice!"]), + Message("user", ["What's the capital of France?"]), + Message("assistant", [Content(type="function_call", name="retrieve_memory", call_id="m1")]), + Message("tool", [Content(type="function_result", call_id="m1", result="User name: Alice")]), + Message("assistant", ["The capital of France is Paris, Alice!"]), + ] + + def split_before_memory(messages: list[Message]) -> tuple[list[Message], list[Message]]: + for index, message in enumerate(messages): + if any(content.name == "retrieve_memory" for content in message.contents): + return messages[:index], messages[index:] + return EvalItem._split_last_turn_static(messages) + + query_messages, response_messages = EvalItem(conversation=conversation).split_messages( + split=cast(Any, split_before_memory) + ) + + assert len(query_messages) == 3 + assert query_messages[-1].role == "user" + assert len(response_messages) == 3 + assert response_messages[0].role == "assistant" + + def test_custom_splitter_fallback(self) -> None: + conversation = [ + Message("user", ["Hello"]), + Message("assistant", ["Hi there!"]), + ] + + def split_before_memory(messages: list[Message]) -> tuple[list[Message], list[Message]]: + for index, message in enumerate(messages): + if any(content.name == "retrieve_memory" for content in message.contents): + return messages[:index], messages[index:] + return EvalItem._split_last_turn_static(messages) + + query_messages, response_messages = EvalItem(conversation=conversation).split_messages( + split=cast(Any, split_before_memory) + ) + + assert [message.role for message in query_messages] == ["user"] + assert [message.role for message in response_messages] == ["assistant"] + + def test_custom_splitter_lambda(self) -> None: + conversation = [ + Message("user", ["A"]), + Message("assistant", ["B"]), + Message("user", ["C"]), + Message("assistant", ["D"]), + ] + + query_messages, response_messages = EvalItem(conversation=conversation).split_messages( + split=cast(Any, lambda messages: (messages[:2], messages[2:])) + ) + + assert len(query_messages) == 2 + assert len(response_messages) == 2 + + def test_item_split_strategy_is_default(self) -> None: + conversation = [ + Message("user", ["First"]), + Message("assistant", ["Response 1"]), + Message("user", ["Second"]), + Message("assistant", ["Response 2"]), + ] + item = EvalItem(conversation=conversation, split_strategy=cast(Any, ConversationSplit.FULL)) + + query_messages, response_messages = item.split_messages() + + assert [message.text for message in query_messages] == ["First"] + assert len(response_messages) == 3 + + def test_explicit_split_overrides_item_strategy(self) -> None: + conversation = [ + Message("user", ["First"]), + Message("assistant", ["Response 1"]), + Message("user", ["Second"]), + Message("assistant", ["Response 2"]), + ] + item = EvalItem(conversation=conversation, split_strategy=cast(Any, ConversationSplit.FULL)) + + query_messages, response_messages = item.split_messages(split=cast(Any, ConversationSplit.LAST_TURN)) + + assert len(query_messages) == 3 + assert query_messages[-1].text == "Second" + assert len(response_messages) == 1 + + def test_no_split_defaults_to_last_turn(self) -> None: + item = EvalItem( + conversation=[ + Message("user", ["Hello"]), + Message("assistant", ["Hi"]), + ] + ) + + query_messages, _ = item.split_messages() + + assert item.split_strategy is None + assert [message.role for message in query_messages] == ["user"] diff --git a/python/packages/foundry/README.md b/python/packages/foundry/README.md index 74e37bbfb04..1bc0051fec0 100644 --- a/python/packages/foundry/README.md +++ b/python/packages/foundry/README.md @@ -2,6 +2,32 @@ This package contains the Microsoft Foundry integrations for Microsoft Agent Framework, including Foundry chat clients, preconfigured Foundry agents, Foundry embedding clients, and Foundry memory providers. +## Evaluations + +`FoundryEvals` implements the provider-neutral `Evaluator` protocol with +Microsoft Foundry's built-in and generated evaluators. Core owns `EvalItem`, +local evaluation, and the `evaluate_agent()` / `evaluate_workflow()` +orchestration functions; this package owns the Foundry Evals data mappings, +wire serialization, submission, polling, and result parsing. + +Use `evaluate_agent()` for the common run-and-evaluate path: + +```python +from agent_framework import evaluate_agent +from agent_framework.foundry import FoundryEvals + +results = await evaluate_agent( + agent=agent, + queries=["What's the weather in Seattle?"], + evaluators=FoundryEvals(), +) +``` + +For manual control, construct public `EvalItem` instances and pass them to +`FoundryEvals.evaluate()`. The Foundry wire format is private to this package. +`evaluate_traces()` and `evaluate_foundry_target()` provide Foundry-specific +entry points for existing traces, response IDs, and registered targets. + ## Concurrent reuse A `FoundryChatClient` instance can be shared by concurrent asynchronous calls on the same event loop. Streaming, diff --git a/python/packages/foundry/agent_framework_foundry/_foundry_evals.py b/python/packages/foundry/agent_framework_foundry/_foundry_evals.py index 25fd3bd817a..37dfb4a7a4a 100644 --- a/python/packages/foundry/agent_framework_foundry/_foundry_evals.py +++ b/python/packages/foundry/agent_framework_foundry/_foundry_evals.py @@ -3,8 +3,7 @@ """Microsoft Foundry Evals integration for Microsoft Agent Framework. Provides ``FoundryEvals``, an ``Evaluator`` implementation backed by Azure AI -Foundry's built-in evaluators. See docs/decisions/0018-foundry-evals-integration.md -for the design rationale. +Foundry's built-in evaluators. Example: @@ -27,13 +26,14 @@ from __future__ import annotations import asyncio +import contextlib +import json import logging from collections.abc import Iterable, Sequence from dataclasses import dataclass from typing import TYPE_CHECKING, Any, cast from agent_framework._evaluation import ( - AgentEvalConverter, ConversationSplit, ConversationSplitter, EvalItem, @@ -44,6 +44,7 @@ ) from agent_framework._feature_stage import ExperimentalFeature, experimental from agent_framework._telemetry import mark_feature_used +from agent_framework._types import Message from openai import AsyncOpenAI from ._chat_client import FoundryChatClient @@ -217,6 +218,69 @@ def _resolve_evaluator(name: str) -> str: # --------------------------------------------------------------------------- +def _convert_message(message: Message) -> list[dict[str, Any]]: + """Convert one Agent Framework message to the Foundry Evals wire format.""" + content_items: list[dict[str, Any]] = [] + tool_results: list[dict[str, Any]] = [] + + for content in message.contents or []: + if content.type == "text" and content.text: + content_items.append({"type": "text", "text": content.text}) + elif content.type in ("data", "uri") and content.uri: + image: dict[str, Any] = { + "type": "input_image", + "image_url": content.uri, + } + if content.media_type: + image["detail"] = "auto" + content_items.append(image) + elif content.type == "function_call": + arguments = content.arguments + if isinstance(arguments, str): + try: + arguments = json.loads(arguments) + except (json.JSONDecodeError, TypeError): + arguments = {"_raw_arguments": "[unparseable]"} + content_items.append({ + "type": "tool_call", + "tool_call_id": content.call_id or "", + "name": content.name or "", + "arguments": arguments if arguments is not None else {}, + }) + elif content.type == "function_result": + result = content.result + if isinstance(result, str): + with contextlib.suppress(json.JSONDecodeError, TypeError): + result = json.loads(result) + tool_results.append({ + "call_id": content.call_id or "", + "result": result, + }) + + if tool_results: + return [ + { + "role": "tool", + "tool_call_id": tool_result["call_id"], + "content": [{"type": "tool_result", "tool_result": tool_result["result"]}], + } + for tool_result in tool_results + ] + if content_items: + return [{"role": message.role, "content": content_items}] + return [ + { + "role": message.role, + "content": [{"type": "text", "text": ""}], + } + ] + + +def _convert_messages(messages: Sequence[Message]) -> list[dict[str, Any]]: + """Convert Agent Framework messages to the Foundry Evals wire format.""" + return [converted for message in messages for converted in _convert_message(message)] + + def _build_testing_criteria( evaluators: Sequence[str | GeneratedEvaluatorRef], model: str, @@ -882,7 +946,7 @@ async def evaluate( evaluators and filters tool evaluators for items without tool definitions. Args: - items: Eval data items from ``AgentEvalConverter.to_eval_item()``. + items: Provider-neutral evaluation data items. eval_name: Display name for the evaluation run. Returns: @@ -919,8 +983,8 @@ async def _evaluate_via_dataset( d: dict[str, Any] = { "query": query_text, "response": response_text, - "query_messages": AgentEvalConverter.convert_messages(query_msgs), - "response_messages": AgentEvalConverter.convert_messages(response_msgs), + "query_messages": _convert_messages(query_msgs), + "response_messages": _convert_messages(response_msgs), } if item.tools: d["tool_definitions"] = [ diff --git a/python/packages/foundry/tests/test_foundry_evals.py b/python/packages/foundry/tests/test_foundry_evals.py index f2b015e04c4..6832f27b7a2 100644 --- a/python/packages/foundry/tests/test_foundry_evals.py +++ b/python/packages/foundry/tests/test_foundry_evals.py @@ -1,6 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. -"""Tests for the AgentEvalConverter, FoundryEvals, and eval helper functions.""" +"""Tests for Foundry Evals wire conversion and evaluation helpers.""" from __future__ import annotations @@ -12,8 +12,6 @@ import pytest from agent_framework import AgentExecutorResponse, AgentResponse, Content, FunctionTool, Message, WorkflowEvent from agent_framework._evaluation import ( - AgentEvalConverter, - ConversationSplit, EvalItem, EvalNotPassedError, EvalResults, @@ -33,6 +31,8 @@ FoundryEvals, _build_item_schema, _build_testing_criteria, + _convert_message, + _convert_messages, _extract_per_evaluator, _extract_result_counts, _extract_rubric_scores, @@ -109,25 +109,25 @@ def test_unknown_raises(self) -> None: # --------------------------------------------------------------------------- -# AgentEvalConverter.convert_message +# Foundry Evals message conversion # --------------------------------------------------------------------------- class TestConvertMessage: def test_user_text_message(self) -> None: msg = Message("user", ["Hello, world!"]) - result = AgentEvalConverter.convert_message(msg) + result = _convert_message(msg) assert len(result) == 1 assert result[0] == {"role": "user", "content": [{"type": "text", "text": "Hello, world!"}]} def test_system_message(self) -> None: msg = Message("system", ["You are helpful."]) - result = AgentEvalConverter.convert_message(msg) + result = _convert_message(msg) assert result[0] == {"role": "system", "content": [{"type": "text", "text": "You are helpful."}]} def test_assistant_text_message(self) -> None: msg = Message("assistant", ["Here is the answer."]) - result = AgentEvalConverter.convert_message(msg) + result = _convert_message(msg) assert len(result) == 1 assert result[0]["role"] == "assistant" assert result[0]["content"] == [{"type": "text", "text": "Here is the answer."}] @@ -144,7 +144,7 @@ def test_assistant_with_tool_call(self) -> None: ), ], ) - result = AgentEvalConverter.convert_message(msg) + result = _convert_message(msg) assert len(result) == 1 assert result[0]["role"] == "assistant" tc = result[0]["content"][0] @@ -164,7 +164,7 @@ def test_assistant_with_zero_argument_tool_call(self) -> None: ), ], ) - result = AgentEvalConverter.convert_message(msg) + result = _convert_message(msg) tc = result[0]["content"][0] assert tc["type"] == "tool_call" assert "arguments" in tc @@ -182,7 +182,7 @@ def test_assistant_text_and_tool_call(self) -> None: ), ], ) - result = AgentEvalConverter.convert_message(msg) + result = _convert_message(msg) assert len(result) == 1 assert result[0]["content"][0] == {"type": "text", "text": "Let me check that."} tc = result[0]["content"][1] @@ -199,7 +199,7 @@ def test_tool_result_message(self) -> None: ), ], ) - result = AgentEvalConverter.convert_message(msg) + result = _convert_message(msg) assert len(result) == 1 assert result[0]["role"] == "tool" assert result[0]["tool_call_id"] == "call_1" @@ -213,7 +213,7 @@ def test_multiple_tool_results(self) -> None: Content.from_function_result(call_id="call_2", result="r2"), ], ) - result = AgentEvalConverter.convert_message(msg) + result = _convert_message(msg) assert len(result) == 2 assert result[0]["tool_call_id"] == "call_1" assert result[1]["tool_call_id"] == "call_2" @@ -228,21 +228,21 @@ def test_non_string_result_kept_as_object(self) -> None: ), ], ) - result = AgentEvalConverter.convert_message(msg) + result = _convert_message(msg) tr = result[0]["content"][0] assert tr["type"] == "tool_result" assert tr["tool_result"] == {"temp": 72, "unit": "F"} def test_empty_message(self) -> None: msg = Message("user", []) - result = AgentEvalConverter.convert_message(msg) + result = _convert_message(msg) assert result[0] == {"role": "user", "content": [{"type": "text", "text": ""}]} def test_user_image_from_data(self) -> None: """Image created via Content.from_data() emits input_image.""" img = Content.from_data(data=b"\x89PNG\r\n\x1a\n", media_type="image/png") msg = Message("user", [img]) - result = AgentEvalConverter.convert_message(msg) + result = _convert_message(msg) assert len(result) == 1 assert result[0]["role"] == "user" part = result[0]["content"][0] @@ -254,7 +254,7 @@ def test_user_image_from_uri(self) -> None: """Image created via Content.from_uri() with an external URL.""" img = Content.from_uri("https://example.com/photo.jpg", media_type="image/jpeg") msg = Message("user", [img]) - result = AgentEvalConverter.convert_message(msg) + result = _convert_message(msg) assert len(result) == 1 part = result[0]["content"][0] assert part["type"] == "input_image" @@ -265,7 +265,7 @@ def test_user_image_uri_without_media_type(self) -> None: """URI content without media_type still emits input_image (no detail key).""" img = Content("uri", uri="https://example.com/pic.png") msg = Message("user", [img]) - result = AgentEvalConverter.convert_message(msg) + result = _convert_message(msg) part = result[0]["content"][0] assert part["type"] == "input_image" assert part["image_url"] == "https://example.com/pic.png" @@ -280,7 +280,7 @@ def test_mixed_text_and_image(self) -> None: Content.from_uri("https://example.com/cat.jpg", media_type="image/jpeg"), ], ) - result = AgentEvalConverter.convert_message(msg) + result = _convert_message(msg) assert len(result) == 1 assert len(result[0]["content"]) == 2 assert result[0]["content"][0] == {"type": "text", "text": "What's in this image?"} @@ -289,7 +289,7 @@ def test_mixed_text_and_image(self) -> None: # --------------------------------------------------------------------------- -# AgentEvalConverter.convert_messages +# Foundry Evals conversation conversion # --------------------------------------------------------------------------- @@ -304,7 +304,7 @@ def test_full_conversation(self) -> None: Message("tool", [Content.from_function_result(call_id="c1", result="Sunny")]), Message("assistant", ["It's sunny in Seattle!"]), ] - result = AgentEvalConverter.convert_messages(messages) + result = _convert_messages(messages) assert len(result) == 4 assert result[0]["role"] == "user" assert result[1]["role"] == "assistant" @@ -327,7 +327,7 @@ def test_multimodal_conversation_preserves_images(self) -> None: ), Message("assistant", ["This is a photo of a sunset over the ocean."]), ] - result = AgentEvalConverter.convert_messages(messages) + result = _convert_messages(messages) assert len(result) == 2 # User message has text + image user_content = result[0]["content"] @@ -339,412 +339,6 @@ def test_multimodal_conversation_preserves_images(self) -> None: assert result[1]["content"] == [{"type": "text", "text": "This is a photo of a sunset over the ocean."}] -# --------------------------------------------------------------------------- -# AgentEvalConverter.extract_tools -# --------------------------------------------------------------------------- - - -class TestExtractTools: - def test_extracts_function_tools(self) -> None: - tool = FunctionTool( - name="get_weather", - description="Get weather for a location", - func=lambda location: f"Sunny in {location}", - ) - agent = MagicMock() - agent.default_options = {"tools": [tool]} - - result = AgentEvalConverter.extract_tools(agent) - assert len(result) == 1 - assert result[0]["name"] == "get_weather" - assert result[0]["description"] == "Get weather for a location" - assert "parameters" in result[0] - - def test_skips_non_function_tools(self) -> None: - agent = MagicMock() - agent.default_options = {"tools": [{"type": "web_search"}, "some_string"]} - - result = AgentEvalConverter.extract_tools(agent) - assert len(result) == 0 - - def test_no_tools(self) -> None: - agent = MagicMock() - agent.default_options = {} - assert AgentEvalConverter.extract_tools(agent) == [] - - def test_no_default_options(self) -> None: - agent = MagicMock(spec=[]) # No attributes - assert AgentEvalConverter.extract_tools(agent) == [] - - -# --------------------------------------------------------------------------- -# AgentEvalConverter.to_eval_item (now returns EvalItem) -# --------------------------------------------------------------------------- - - -class TestToEvalItem: - def test_string_query(self) -> None: - response = AgentResponse(messages=[Message("assistant", ["The weather is sunny."])]) - item = AgentEvalConverter.to_eval_item(query="What's the weather?", response=response) - - assert isinstance(item, EvalItem) - assert item.query == "What's the weather?" - assert item.response == "The weather is sunny." - assert len(item.conversation) == 2 - assert item.conversation[0].role == "user" - assert item.conversation[1].role == "assistant" - - def test_message_query(self) -> None: - input_msgs = [ - Message("system", ["Be helpful."]), - Message("user", ["Hello"]), - ] - response = AgentResponse(messages=[Message("assistant", ["Hi there!"])]) - item = AgentEvalConverter.to_eval_item(query=input_msgs, response=response) - - assert item.query == "Hello" # Only user messages - assert len(item.conversation) == 3 # system + user + assistant - - def test_with_context(self) -> None: - response = AgentResponse(messages=[Message("assistant", ["Answer."])]) - item = AgentEvalConverter.to_eval_item( - query="Question?", - response=response, - context="Some reference document.", - ) - assert item.context == "Some reference document." - - def test_with_explicit_tools(self) -> None: - tool = FunctionTool( - name="search", - description="Search the web", - func=lambda q: f"Results for {q}", - ) - response = AgentResponse(messages=[Message("assistant", ["Found it."])]) - item = AgentEvalConverter.to_eval_item( - query="Find info", - response=response, - tools=[tool], - ) - assert item.tools is not None - assert len(item.tools) == 1 - assert item.tools[0].name == "search" - - def test_with_agent_tools(self) -> None: - tool = FunctionTool(name="calc", description="Calculate", func=lambda x: str(x)) - agent = MagicMock() - agent.default_options = {"tools": [tool]} - - response = AgentResponse(messages=[Message("assistant", ["42"])]) - item = AgentEvalConverter.to_eval_item( - query="What is 6*7?", - response=response, - agent=agent, - ) - assert item.tools is not None - assert item.tools[0].name == "calc" - - def test_explicit_tools_override_agent(self) -> None: - agent_tool = FunctionTool(name="agent_tool", description="from agent", func=lambda: "") - explicit_tool = FunctionTool(name="explicit_tool", description="explicit", func=lambda: "") - - agent = MagicMock() - agent.default_options = {"tools": [agent_tool]} - - response = AgentResponse(messages=[Message("assistant", ["Done"])]) - item = AgentEvalConverter.to_eval_item( - query="Test", - response=response, - agent=agent, - tools=[explicit_tool], - ) - assert item.tools is not None - assert len(item.tools) == 1 - assert item.tools[0].name == "explicit_tool" - - def test_split_messages_format(self) -> None: - """split_messages() should split conversation at last user message.""" - response = AgentResponse(messages=[Message("assistant", ["Answer"])]) - item = AgentEvalConverter.to_eval_item( - query="Q", - response=response, - tools=[FunctionTool(name="t", description="d", func=lambda: "")], - ) - query_msgs, response_msgs = item.split_messages() - # Single-turn: query has just the user msg, response has the assistant msg - assert len(query_msgs) == 1 - assert query_msgs[0].role == "user" - assert len(response_msgs) == 1 - assert response_msgs[0].role == "assistant" - # Tools preserved on item - assert item.tools is not None - assert len(item.tools) == 1 - assert item.tools[0].name == "t" - - def test_split_messages_multiturn_preserves_interleaving(self) -> None: - """Multi-turn split_messages() splits at last user message, preserving interleaving.""" - conversation = [ - Message("user", ["What's the weather?"]), - Message("assistant", ["It's sunny in Seattle."]), - Message("user", ["And tomorrow?"]), - Message("assistant", [Content(type="function_call", name="get_forecast")]), - Message("tool", [Content(type="function_result", result="Rain expected")]), - Message("assistant", ["Rain is expected tomorrow."]), - ] - item = EvalItem(conversation=conversation) - query_msgs, response_msgs = item.split_messages() - # query_messages: everything up to and including the last user message - assert len(query_msgs) == 3 # user, assistant, user - assert query_msgs[0].role == "user" - assert query_msgs[1].role == "assistant" # interleaved! - assert query_msgs[2].role == "user" - # response_messages: everything after the last user message - assert len(response_msgs) == 3 # assistant(tool_call), tool, assistant - assert response_msgs[0].role == "assistant" - assert response_msgs[1].role == "tool" - assert response_msgs[2].role == "assistant" - - def test_split_messages_full_split(self) -> None: - """ConversationSplit.FULL splits after the first user message.""" - conversation = [ - Message("user", ["What's the weather?"]), - Message("assistant", ["It's 62°F in Seattle."]), - Message("user", ["And tomorrow?"]), - Message("assistant", ["Rain is expected tomorrow."]), - ] - item = EvalItem(conversation=conversation) - query_msgs, response_msgs = item.split_messages(split=cast(Any, ConversationSplit.FULL)) - # query_messages: just the first user message - assert len(query_msgs) == 1 - assert query_msgs[0].role == "user" - assert query_msgs[0].text == "What's the weather?" - # response_messages: everything after the first user message - assert len(response_msgs) == 3 - assert response_msgs[0].role == "assistant" - assert response_msgs[1].role == "user" - assert response_msgs[2].role == "assistant" - - def test_split_messages_full_split_with_system(self) -> None: - """FULL split includes system messages before the first user message in query.""" - conversation = [ - Message("system", ["You are a weather assistant."]), - Message("user", ["What's the weather?"]), - Message("assistant", ["It's sunny."]), - ] - item = EvalItem(conversation=conversation) - query_msgs, response_msgs = item.split_messages(split=cast(Any, ConversationSplit.FULL)) - # query includes system + first user - assert len(query_msgs) == 2 - assert query_msgs[0].role == "system" - assert query_msgs[1].role == "user" - assert len(response_msgs) == 1 - - def test_split_messages_full_split_with_tools(self) -> None: - """FULL split puts all tool interactions in response_messages.""" - conversation = [ - Message("user", ["What's the weather?"]), - Message("assistant", [Content(type="function_call", name="get_weather")]), - Message("tool", [Content(type="function_result", result="62°F")]), - Message("assistant", ["It's 62°F."]), - Message("user", ["Thanks!"]), - Message("assistant", ["You're welcome!"]), - ] - item = EvalItem(conversation=conversation) - query_msgs, response_msgs = item.split_messages(split=cast(Any, ConversationSplit.FULL)) - assert len(query_msgs) == 1 - assert len(response_msgs) == 5 - - def test_split_messages_last_turn_is_default(self) -> None: - """Default split_messages() uses LAST_TURN split.""" - conversation = [ - Message("user", ["Hello"]), - Message("assistant", ["Hi there"]), - Message("user", ["Bye"]), - Message("assistant", ["Goodbye"]), - ] - item = EvalItem(conversation=conversation) - q_default, r_default = item.split_messages() - q_explicit, r_explicit = item.split_messages(split=cast(Any, ConversationSplit.LAST_TURN)) - assert [m.role for m in q_default] == [m.role for m in q_explicit] - assert [m.text for m in q_default] == [m.text for m in q_explicit] - assert [m.role for m in r_default] == [m.role for m in r_explicit] - assert [m.text for m in r_default] == [m.text for m in r_explicit] - - def test_per_turn_items_simple(self) -> None: - """per_turn_items produces one EvalItem per user message.""" - conversation = [ - Message("user", ["What's the weather?"]), - Message("assistant", ["It's 62°F."]), - Message("user", ["And tomorrow?"]), - Message("assistant", ["Rain expected."]), - ] - items = EvalItem.per_turn_items(conversation) - assert len(items) == 2 - - # Turn 1 - assert items[0].query == "What's the weather?" - assert items[0].response == "It's 62°F." - assert len(items[0].conversation) == 2 - - # Turn 2 — includes cumulative context; query joins all user texts in query split - assert items[1].query == "What's the weather? And tomorrow?" - assert items[1].response == "Rain expected." - assert len(items[1].conversation) == 4 - - def test_per_turn_items_with_tools(self) -> None: - """per_turn_items handles tool calls within a turn.""" - conversation = [ - Message("user", ["Check weather"]), - Message("assistant", [Content(type="function_call", name="get_weather")]), - Message("tool", [Content(type="function_result", result="sunny")]), - Message("assistant", ["It's sunny."]), - Message("user", ["Thanks"]), - Message("assistant", ["You're welcome!"]), - ] - tool_objs = [_make_tool("get_weather")] - items = EvalItem.per_turn_items(conversation, tools=cast(Any, tool_objs)) - assert len(items) == 2 - - # Turn 1: response includes tool_call, tool_result, and final assistant - assert items[0].response == "It's sunny." - assert items[0].tools == tool_objs - assert len(items[0].conversation) == 4 # user, assistant(tool), tool, assistant - - # Turn 2 - assert items[1].response == "You're welcome!" - assert len(items[1].conversation) == 6 # full conversation - - def test_per_turn_items_empty(self) -> None: - """per_turn_items returns empty list when no user messages.""" - items = EvalItem.per_turn_items([Message("assistant", ["Hello"])]) - assert items == [] - - def test_per_turn_items_single_turn(self) -> None: - """per_turn_items with single turn produces one item.""" - conversation = [ - Message("user", ["Hi"]), - Message("assistant", ["Hello!"]), - ] - items = EvalItem.per_turn_items(conversation) - assert len(items) == 1 - assert items[0].query == "Hi" - assert items[0].response == "Hello!" - - def test_custom_splitter_callable(self) -> None: - """Custom callable splitter is used by split_messages().""" - conversation = [ - Message("user", ["Remember my name is Alice"]), - Message("assistant", ["Got it, Alice!"]), - Message("user", ["What's the capital of France?"]), - Message("assistant", [Content(type="function_call", name="retrieve_memory", call_id="m1")]), - Message("tool", [Content(type="function_result", call_id="m1", result="User name: Alice")]), - Message("assistant", ["The capital of France is Paris, Alice!"]), - ] - - def split_before_memory(conversation): - """Split just before the memory retrieval tool call.""" - for i, msg in enumerate(conversation): - for c in msg.contents: - if c.name == "retrieve_memory": - return conversation[:i], conversation[i:] - return EvalItem._split_last_turn_static(conversation) - - item = EvalItem(conversation=conversation) - query_msgs, response_msgs = item.split_messages(split=split_before_memory) - - # split_before_memory finds "retrieve_memory" at conv[3] (assistant tool_call msg) - # query = conv[:3] = [user, assistant, user] - # response = conv[3:] = [assistant(tool_call), tool, assistant] - assert len(query_msgs) == 3 - assert query_msgs[-1].role == "user" - assert len(response_msgs) == 3 - assert response_msgs[0].role == "assistant" # the tool_call msg - - def test_custom_splitter_with_fallback(self) -> None: - """Custom splitter falls back to _split_last_turn_static when pattern not found.""" - conversation = [ - Message("user", ["Hello"]), - Message("assistant", ["Hi there!"]), - ] - - def split_before_memory(conversation): - for i, msg in enumerate(conversation): - for c in msg.contents: - if c.name == "retrieve_memory": - return conversation[:i], conversation[i:] - return EvalItem._split_last_turn_static(conversation) - - item = EvalItem(conversation=conversation) - query_msgs, response_msgs = item.split_messages(split=split_before_memory) - # Falls back to last-turn split - assert len(query_msgs) == 1 - assert query_msgs[0].role == "user" - assert len(response_msgs) == 1 - assert response_msgs[0].role == "assistant" - - def test_custom_splitter_lambda(self) -> None: - """A lambda works as a custom splitter.""" - conversation = [ - Message("user", ["A"]), - Message("assistant", ["B"]), - Message("user", ["C"]), - Message("assistant", ["D"]), - ] - # Split at index 2 (arbitrary) - item = EvalItem(conversation=conversation) - query_msgs, response_msgs = item.split_messages(split=lambda conversation: (conversation[:2], conversation[2:])) - assert len(query_msgs) == 2 - assert len(response_msgs) == 2 - - def test_split_strategy_on_item_used_by_split_messages(self) -> None: - """split_strategy field on EvalItem is used as default by split_messages().""" - conversation = [ - Message("user", ["First"]), - Message("assistant", ["Response 1"]), - Message("user", ["Second"]), - Message("assistant", ["Response 2"]), - ] - item = EvalItem( - conversation=conversation, - split_strategy=cast(Any, ConversationSplit.FULL), - ) - # split_messages() with no split arg should use item.split_strategy - query_msgs, response_msgs = item.split_messages() - assert len(query_msgs) == 1 # FULL: just first user msg - assert query_msgs[0].text == "First" - assert len(response_msgs) == 3 - - def test_explicit_split_overrides_item_split_strategy(self) -> None: - """Explicit split= arg to split_messages() overrides item.split_strategy.""" - conversation = [ - Message("user", ["First"]), - Message("assistant", ["Response 1"]), - Message("user", ["Second"]), - Message("assistant", ["Response 2"]), - ] - item = EvalItem( - conversation=conversation, - split_strategy=cast(Any, ConversationSplit.FULL), - ) - # Explicit split= should override split_strategy - query_msgs, response_msgs = item.split_messages(split=cast(Any, ConversationSplit.LAST_TURN)) - assert len(query_msgs) == 3 # LAST_TURN: up to last user - assert query_msgs[-1].text == "Second" - assert len(response_msgs) == 1 - - def test_no_split_defaults_to_last_turn(self) -> None: - """When neither split= nor split_strategy is set, defaults to LAST_TURN.""" - conversation = [ - Message("user", ["Hello"]), - Message("assistant", ["Hi"]), - ] - item = EvalItem(conversation=conversation) - assert item.split_strategy is None - query_msgs, response_msgs = item.split_messages() - assert len(query_msgs) == 1 - assert query_msgs[0].role == "user" - - # --------------------------------------------------------------------------- # _build_testing_criteria # --------------------------------------------------------------------------- diff --git a/python/samples/05-end-to-end/evaluation/foundry_evals/README.md b/python/samples/05-end-to-end/evaluation/foundry_evals/README.md index c423d2b600b..cf33fa28cd0 100644 --- a/python/samples/05-end-to-end/evaluation/foundry_evals/README.md +++ b/python/samples/05-end-to-end/evaluation/foundry_evals/README.md @@ -15,15 +15,24 @@ These samples demonstrate evaluating agent-framework agents using Microsoft Foun ### `evaluate_agent_sample.py` — Dataset Evaluation (Path 3) -The dev inner loop. Two patterns from simplest to most control: - -1. **`evaluate_agent()`** — One call: runs agent → converts → evaluates -2. **`FoundryEvals.evaluate()`** — Run agent yourself, convert with `AgentEvalConverter`, inspect/modify, then evaluate +The dev inner loop. Pass existing responses or let `evaluate_agent()` run the +agent against test queries before submitting provider-neutral `EvalItem` data +to Foundry. ```bash uv run samples/05-end-to-end/evaluation/foundry_evals/evaluate_agent_sample.py ``` +### `evaluate_tool_calls_sample.py` — Explicit Eval Items + +For more control, run the agent yourself, construct public `EvalItem` instances +from the conversation and typed tools, inspect or modify them, and pass them to +`FoundryEvals.evaluate()`. + +```bash +uv run samples/05-end-to-end/evaluation/foundry_evals/evaluate_tool_calls_sample.py +``` + ### `evaluate_traces_sample.py` — Trace & Response Evaluation (Path 1) Evaluate what already happened — zero changes to agent code: @@ -71,5 +80,5 @@ Create a `.env` file with configuration as in the `.env.example` file in this fo - **"I want to test my agent during development"** → `evaluate_agent_sample.py`, Pattern 1 - **"I want to evaluate past agent runs"** → `evaluate_traces_sample.py` -- **"I want to inspect/modify eval data before submitting"** → `evaluate_agent_sample.py`, Pattern 2 +- **"I want to inspect/modify eval data before submitting"** → `evaluate_tool_calls_sample.py` - **"I want to score against a custom rubric I created in Foundry"** → `evaluate_with_rubric_sample.py` diff --git a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_tool_calls_sample.py b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_tool_calls_sample.py index d9fcf051ce5..019c0709d79 100644 --- a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_tool_calls_sample.py +++ b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_tool_calls_sample.py @@ -13,7 +13,7 @@ import asyncio import os -from agent_framework import Agent, AgentEvalConverter +from agent_framework import Agent, EvalItem, Message, tool from agent_framework.foundry import FoundryChatClient, FoundryEvals from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -21,6 +21,7 @@ load_dotenv() +@tool def get_weather(location: str) -> str: """Get the current weather for a location.""" weather_data = { @@ -31,6 +32,7 @@ def get_weather(location: str) -> str: return weather_data.get(location.lower(), f"Weather data not available for {location}") +@tool def get_flight_price(origin: str, destination: str) -> str: """Get the price of a flight between two cities.""" return f"Flights from {origin} to {destination}: $450 round-trip" @@ -65,7 +67,10 @@ async def main() -> None: print(f"Query: {q}") print(f"Response: {response.text[:100]}...") - item = AgentEvalConverter.to_eval_item(query=q, response=response, agent=agent) + item = EvalItem( + conversation=[Message("user", [q]), *response.messages], + tools=[get_weather, get_flight_price], + ) items.append(item) print(f" Has tools: {item.tools is not None}") From e5b55c757b77be6da84ec6464efd2438df52b53f Mon Sep 17 00:00:00 2001 From: Dineshsuriya D <43177361+droideronline@users.noreply.github.com> Date: Fri, 4 Sep 2026 08:16:58 +0000 Subject: [PATCH 16/85] Python: Update GitHub Copilot SDK to 1.0.11 (#8002) * Python: update GitHub Copilot SDK to 1.0.11 * Fix Copilot SDK 1.0.11 permission typing * Preserve Copilot permission handler compatibility * Isolate Copilot permission context compatibility type --- .../agent_framework_github_copilot/_agent.py | 30 ++++++++++++++----- python/packages/github_copilot/pyproject.toml | 2 +- .../tests/test_github_copilot_agent.py | 26 +++++++++++++--- .../github_copilot_with_file_operations.py | 4 +-- .../github_copilot_with_function_approval.py | 5 ++-- ...ithub_copilot_with_multiple_permissions.py | 4 +-- .../github_copilot_with_shell.py | 4 +-- .../github_copilot/github_copilot_with_url.py | 4 +-- python/uv.lock | 12 +++----- 9 files changed, 61 insertions(+), 30 deletions(-) diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index 2f7b963cb75..c58e5edf711 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -77,6 +77,7 @@ Attachment, BlobAttachment, MCPServerConfig, + PermissionInvocation, PermissionRequestResult, PreToolUseHandler, PreToolUseHookOutput, @@ -110,14 +111,24 @@ DEFAULT_TIMEOUT_SECONDS: float = 60.0 """Default timeout in seconds for Copilot requests.""" +_PermissionHandlerContext = Any +"""Compatibility context accepted by permission handlers across SDK versions.""" + PermissionHandlerType = Callable[ - [PermissionRequest, dict[str, str]], "PermissionRequestResult | Awaitable[PermissionRequestResult]" + [PermissionRequest, _PermissionHandlerContext], + "PermissionRequestResult | Awaitable[PermissionRequestResult]", ] """Type for permission request handlers. Supports both sync and async callbacks.""" -AsyncPermissionHandlerType = Callable[[PermissionRequest, dict[str, str]], "Awaitable[PermissionRequestResult]"] +AsyncPermissionHandlerType = Callable[ + [PermissionRequest, _PermissionHandlerContext], "Awaitable[PermissionRequestResult]" +] """Type for permission request handlers that are always asynchronous.""" +_SdkAsyncPermissionHandlerType = Callable[ + [PermissionRequest, PermissionInvocation], "Awaitable[PermissionRequestResult]" +] + FunctionApprovalCallback = Callable[[Content], "bool | Awaitable[bool]"] """Deprecated approval callback for ``FunctionTool`` instances declared with @@ -171,7 +182,7 @@ async def _resolve_function_approval( def _deny_all_permissions( _request: PermissionRequest, - _invocation: dict[str, str], + _invocation: _PermissionHandlerContext, ) -> PermissionRequestResult: """Default permission handler that denies all requests.""" return PermissionDecisionUserNotAvailable() @@ -322,7 +333,7 @@ def _normalize_permission_decision( return PermissionDecisionApproveForSession(approval=approval) -def _with_normalized_permission_decisions(handler: PermissionHandlerType) -> AsyncPermissionHandlerType: +def _with_normalized_permission_decisions(handler: PermissionHandlerType) -> _SdkAsyncPermissionHandlerType: """Wrap a permission handler so its decisions are normalized before reaching the SDK. Exceptions raised by ``handler`` deliberately propagate: the SDK already catches them @@ -335,8 +346,10 @@ def _with_normalized_permission_decisions(handler: PermissionHandlerType) -> Asy An async handler delegating to ``handler`` and normalizing its result. """ - async def normalized_handler(request: PermissionRequest, invocation: dict[str, str]) -> PermissionRequestResult: - result = handler(request, invocation) + async def normalized_handler( + request: PermissionRequest, invocation: PermissionInvocation + ) -> PermissionRequestResult: + result = handler(request, cast(PermissionInvocation, dict(invocation))) if inspect.isawaitable(result): result = await result return _normalize_permission_decision(result, request) @@ -1468,7 +1481,10 @@ def _build_session_kwargs( if not kwargs.get("model"): kwargs["model"] = self._settings.get("model") or None kwargs["on_permission_request"] = _with_normalized_permission_decisions( - opts.get("on_permission_request") or self._permission_handler or _deny_all_permissions + cast( + PermissionHandlerType, + opts.get("on_permission_request") or self._permission_handler or _deny_all_permissions, + ) ) kwargs["hooks"] = self._build_session_hooks(all_tools, kwargs) diff --git a/python/packages/github_copilot/pyproject.toml b/python/packages/github_copilot/pyproject.toml index ef660ec3032..c9205a397d8 100644 --- a/python/packages/github_copilot/pyproject.toml +++ b/python/packages/github_copilot/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.15.0,<2", - "github-copilot-sdk==1.0.2; python_version >= '3.11'", + "github-copilot-sdk==1.0.11; python_version >= '3.11'", ] [tool.uv] diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index 6e17db2f733..3643ae9e591 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -28,7 +28,7 @@ tool, ) from agent_framework.exceptions import AgentException -from copilot.session import PermissionHandler, PreToolUseHookInput +from copilot.session import PermissionHandler, PermissionInvocation, PreToolUseHookInput from copilot.session_events import ( AssistantUsageData, Data, @@ -1329,7 +1329,7 @@ async def test_resume_session_includes_tools_and_permissions( from copilot.session import PermissionDecisionApproveOnce, PermissionRequestResult from copilot.session_events import PermissionRequest - def my_handler(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: + def my_handler(request: PermissionRequest, context: PermissionInvocation) -> PermissionRequestResult: return PermissionDecisionApproveOnce() def my_tool(arg: str) -> str: @@ -2603,7 +2603,7 @@ def test_permission_handler_set_when_provided(self) -> None: from copilot.session import PermissionDecisionApproveOnce, PermissionRequestResult from copilot.session_events import PermissionRequest - def approve_shell(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: + def approve_shell(request: PermissionRequest, context: PermissionInvocation) -> PermissionRequestResult: if request.kind == "shell": return PermissionDecisionApproveOnce() return PermissionDecisionDeniedInteractivelyByUser() @@ -2621,7 +2621,7 @@ async def test_session_config_includes_permission_handler( from copilot.session import PermissionDecisionApproveOnce, PermissionRequestResult from copilot.session_events import PermissionRequest - def approve_shell_read(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: + def approve_shell_read(request: PermissionRequest, context: PermissionInvocation) -> PermissionRequestResult: if request.kind in ("shell", "read"): return PermissionDecisionApproveOnce() return PermissionDecisionDeniedInteractivelyByUser() @@ -2943,6 +2943,24 @@ async def async_handler(_request: Any, _invocation: Any) -> Any: assert isinstance(result, PermissionDecisionApproveForSession) assert isinstance(result.approval, PermissionDecisionApproveForSessionApprovalCommands) + async def test_legacy_dict_permission_handlers_are_supported(self) -> None: + """The wrapper continues to support handlers typed for the legacy dictionary context.""" + from copilot.generated.rpc import PermissionDecisionApproveOnce + from copilot.session_events import PermissionRequest + + received_context: dict[str, str] = {} + + def legacy_handler(request: PermissionRequest, context: dict[str, str]) -> Any: + received_context.update(context) + return PermissionDecisionApproveOnce() + + from agent_framework_github_copilot._agent import _with_normalized_permission_decisions + + handler = _with_normalized_permission_decisions(legacy_handler) # type: ignore[arg-type] + await handler(shell_request(["ls"]), {"session_id": "test-session"}) + + assert received_context == {"session_id": "test-session"} + async def test_handler_exceptions_propagate(self) -> None: """Handler failures must keep reaching the SDK, which denies the request.""" from agent_framework_github_copilot._agent import _with_normalized_permission_decisions diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_file_operations.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_file_operations.py index 7f363add9a4..6fac5d3eb82 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_file_operations.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_file_operations.py @@ -15,11 +15,11 @@ from agent_framework.github import GitHubCopilotAgent, GitHubCopilotOptions from copilot.generated.rpc import PermissionDecisionDeniedInteractivelyByUser -from copilot.session import PermissionHandler, PermissionRequestResult +from copilot.session import PermissionHandler, PermissionInvocation, PermissionRequestResult from copilot.session_events import PermissionRequest -async def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: +async def prompt_permission(request: PermissionRequest, context: PermissionInvocation) -> PermissionRequestResult: """Permission handler that prompts the user for approval.""" print(f"\n[Permission Request: {request.kind}]") response = (await asyncio.to_thread(input, "Approve? (y/n): ")).strip().lower() diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_function_approval.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_function_approval.py index 0502be7ea18..a191fab6281 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_function_approval.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_function_approval.py @@ -35,6 +35,7 @@ from copilot.generated.rpc import PermissionDecisionReject from copilot.session import ( PermissionHandler, + PermissionInvocation, PermissionRequestResult, PreToolUseHookInput, PreToolUseHookOutput, @@ -63,13 +64,13 @@ def get_weather_detail(location: Annotated[str, "The city and state, e.g. San Fr ) -def approve_all_requests(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: +def approve_all_requests(request: PermissionRequest, context: PermissionInvocation) -> PermissionRequestResult: """Permission handler that approves every request, including the gated tool.""" print(f"\n [Permission requested: {request.kind}] -> approved") return PermissionHandler.approve_all(request, context) -def deny_all_requests(request: PermissionRequest, _context: dict[str, str]) -> PermissionRequestResult: +def deny_all_requests(request: PermissionRequest, _context: PermissionInvocation) -> PermissionRequestResult: """Permission handler that denies every request.""" print(f"\n [Permission requested: {request.kind}] -> denied") return PermissionDecisionReject(feedback="Denied by the operator's policy.") diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_multiple_permissions.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_multiple_permissions.py index 4e375e181a4..60d6377cd98 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_multiple_permissions.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_multiple_permissions.py @@ -20,11 +20,11 @@ import asyncio from agent_framework.github import GitHubCopilotAgent, GitHubCopilotOptions -from copilot.session import PermissionHandler, PermissionRequestResult +from copilot.session import PermissionHandler, PermissionInvocation, PermissionRequestResult from copilot.session_events import PermissionRequest -def approve_and_log(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: +def approve_and_log(request: PermissionRequest, context: PermissionInvocation) -> PermissionRequestResult: """Permission handler that auto-approves and logs each permission kind.""" print(f" [Permission: {request.kind}]", flush=True) return PermissionHandler.approve_all(request, context) diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_shell.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_shell.py index 0cd6ba3728a..50a84917d6b 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_shell.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_shell.py @@ -15,11 +15,11 @@ from agent_framework.github import GitHubCopilotAgent, GitHubCopilotOptions from copilot.generated.rpc import PermissionDecisionUserNotAvailable -from copilot.session import PermissionHandler, PermissionRequestResult +from copilot.session import PermissionHandler, PermissionInvocation, PermissionRequestResult from copilot.session_events import PermissionRequest -def approve_and_log(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: +def approve_and_log(request: PermissionRequest, context: PermissionInvocation) -> PermissionRequestResult: """Permission handler that approves only shell commands and logs them.""" if request.kind == "shell": print(f"\n [Permission: {request.kind}]", flush=True) diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_url.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_url.py index eb3edc5296b..1d57a2b0765 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_url.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_url.py @@ -15,11 +15,11 @@ from agent_framework.github import GitHubCopilotAgent, GitHubCopilotOptions from copilot.generated.rpc import PermissionDecisionUserNotAvailable -from copilot.session import PermissionHandler, PermissionRequestResult +from copilot.session import PermissionHandler, PermissionInvocation, PermissionRequestResult from copilot.session_events import PermissionRequest -def approve_and_log(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: +def approve_and_log(request: PermissionRequest, context: PermissionInvocation) -> PermissionRequestResult: """Permission handler that approves only URL requests and logs them.""" if request.kind == "url": print(f"\n [Permission: {request.kind}]", flush=True) diff --git a/python/uv.lock b/python/uv.lock index b612dd2b739..6a3b1a24c00 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -692,7 +692,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = "==1.0.2" }, + { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = "==1.0.11" }, ] [[package]] @@ -2835,19 +2835,15 @@ wheels = [ [[package]] name = "github-copilot-sdk" -version = "1.0.2" +version = "1.0.11" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/2c/3d3ecfe500c0ba7d3127737b1aa22f19ff1a19e6e86360bfdca3f02a2c09/github_copilot_sdk-1.0.2-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:856dfc8370f36f6efd8a2aa1dd40f82c1a6d0573d0577eaff1f6affb73ed29ad", size = 97329153, upload-time = "2026-06-18T00:56:20.653Z" }, - { url = "https://files.pythonhosted.org/packages/3d/ab/d4ab9320e50a1381d436401f75f8ad54fa57541324451ef2a96db6258464/github_copilot_sdk-1.0.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c78c610c3fd7be82ab69a76fb21e04eb89482072e07e31ad1ca63c893aad0c6d", size = 90809903, upload-time = "2026-06-18T00:56:25.128Z" }, - { url = "https://files.pythonhosted.org/packages/24/33/880d681e5d661f8c66ac02c0b17091538ec716b62534a0d08859d48f7f99/github_copilot_sdk-1.0.2-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:32a844e7fa9644614f9092eeca853368debfed527baaaebfd1a4d4ee89cbdcbd", size = 99564035, upload-time = "2026-06-18T00:56:29.401Z" }, - { url = "https://files.pythonhosted.org/packages/d5/44/ce7485fada7a96ece22d7d0a0a18804d09dca2911b544f2fd90f4d7c02ff/github_copilot_sdk-1.0.2-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:dd1faaaf896a7f97d9c36a1a40e968132e141019ce5a11c4666b089b7d6ea8cd", size = 97202424, upload-time = "2026-06-18T00:56:33.598Z" }, - { url = "https://files.pythonhosted.org/packages/76/90/755c9908a9bbdeb76ad3361d9e23c89ce7520c673745166a5ee2103443d1/github_copilot_sdk-1.0.2-py3-none-win_amd64.whl", hash = "sha256:d932666bba33a840421a183079141ea6b7a78ce4f84d30fb1ad035ee00b464e4", size = 93640286, upload-time = "2026-06-18T00:56:37.73Z" }, - { url = "https://files.pythonhosted.org/packages/f5/74/22204ba7547f4ea849993920240325cdb9851df0177cc808cb20ca04ef5a/github_copilot_sdk-1.0.2-py3-none-win_arm64.whl", hash = "sha256:7a7d99a2fb1c2fb1c05753110961fb99489201fc91a16be5297ef1c2ca2333f2", size = 92382516, upload-time = "2026-06-18T00:56:41.148Z" }, + { url = "https://files.pythonhosted.org/packages/67/ac/175cbb71fe637d963a885248a421040c9d500ea6390ed3b88bc68ecf51ca/github_copilot_sdk-1.0.11-py3-none-any.whl", hash = "sha256:6f664c7b843c34ab5a7455f7effb4d339eae75969d2b72f43c2e6214c9c637dc", size = 486719, upload-time = "2026-08-14T16:12:20.01Z" }, ] [[package]] From 48032ebc8aa7ebfdd1fe448d3495f0544239e96a Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 4 Sep 2026 08:21:27 +0000 Subject: [PATCH 17/85] Python: support FastAPI 0.141 (#8052) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: aa81fffd-c616-4b3b-bd3f-c407e5959765 --- python/packages/ag-ui/pyproject.toml | 2 +- python/packages/devui/pyproject.toml | 2 +- python/packages/hosting-responses/pyproject.toml | 2 +- .../af-hosting/local_responses/pyproject.toml | 2 +- .../local_responses_harness/pyproject.toml | 2 +- .../local_responses_workflow/pyproject.toml | 2 +- .../04-hosting/af-hosting/local_telegram/app.py | 2 +- .../af-hosting/local_telegram/pyproject.toml | 2 +- python/uv.lock | 12 ++++++------ 9 files changed, 14 insertions(+), 14 deletions(-) diff --git a/python/packages/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml index 116e727137c..09f114de5f4 100644 --- a/python/packages/ag-ui/pyproject.toml +++ b/python/packages/ag-ui/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ dependencies = [ "agent-framework-core>=1.17.0,<2", "ag-ui-protocol>=0.1.19,<0.2", - "fastapi>=0.121.0,<0.140.0", + "fastapi>=0.121.0,<0.142.0", "httpx>=0.28.1,<1", "sse-starlette>=3.4.5,<4", "uvicorn[standard]>=0.30.0,<1" diff --git a/python/packages/devui/pyproject.toml b/python/packages/devui/pyproject.toml index 1ee46d9a39f..173f378c044 100644 --- a/python/packages/devui/pyproject.toml +++ b/python/packages/devui/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ "agent-framework-core>=1.17.0,<2", "openai>=2.45.0,<4", "opentelemetry-sdk>=1.39.0,<2", - "fastapi>=0.115.0,<0.138.1", + "fastapi>=0.115.0,<0.142.0", "uvicorn[standard]>=0.30.0,<1" ] diff --git a/python/packages/hosting-responses/pyproject.toml b/python/packages/hosting-responses/pyproject.toml index 9f47ef04835..c51ffcae8a4 100644 --- a/python/packages/hosting-responses/pyproject.toml +++ b/python/packages/hosting-responses/pyproject.toml @@ -30,7 +30,7 @@ dependencies = [ [dependency-groups] test = [ - "fastapi>=0.115.0,<0.138.1", + "fastapi>=0.115.0,<0.142.0", "httpx>=0.28.1", ] diff --git a/python/samples/04-hosting/af-hosting/local_responses/pyproject.toml b/python/samples/04-hosting/af-hosting/local_responses/pyproject.toml index f151596f38a..a70ff849d80 100644 --- a/python/samples/04-hosting/af-hosting/local_responses/pyproject.toml +++ b/python/samples/04-hosting/af-hosting/local_responses/pyproject.toml @@ -8,7 +8,7 @@ dependencies = [ "agent-framework-hosting", "agent-framework-hosting-responses", "aiohttp>=3.13.5", - "fastapi>=0.115.0,<0.138.1", + "fastapi>=0.115.0,<0.142.0", "hypercorn>=0.17", ] diff --git a/python/samples/04-hosting/af-hosting/local_responses_harness/pyproject.toml b/python/samples/04-hosting/af-hosting/local_responses_harness/pyproject.toml index 0a00e82d248..7bfb2fa1190 100644 --- a/python/samples/04-hosting/af-hosting/local_responses_harness/pyproject.toml +++ b/python/samples/04-hosting/af-hosting/local_responses_harness/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "agent-framework-hosting-responses", "azure-identity", "aiohttp>=3.13.5", - "fastapi>=0.115.0,<0.138.1", + "fastapi>=0.115.0,<0.142.0", "hypercorn>=0.17", ] diff --git a/python/samples/04-hosting/af-hosting/local_responses_workflow/pyproject.toml b/python/samples/04-hosting/af-hosting/local_responses_workflow/pyproject.toml index 53f9b0cbe0c..205456edd16 100644 --- a/python/samples/04-hosting/af-hosting/local_responses_workflow/pyproject.toml +++ b/python/samples/04-hosting/af-hosting/local_responses_workflow/pyproject.toml @@ -8,7 +8,7 @@ dependencies = [ "agent-framework-hosting", "agent-framework-hosting-responses", "aiohttp>=3.13.5", - "fastapi>=0.115.0,<0.138.1", + "fastapi>=0.115.0,<0.142.0", "hypercorn>=0.17", ] diff --git a/python/samples/04-hosting/af-hosting/local_telegram/app.py b/python/samples/04-hosting/af-hosting/local_telegram/app.py index 922e9ffaf8f..be3db48c1b7 100644 --- a/python/samples/04-hosting/af-hosting/local_telegram/app.py +++ b/python/samples/04-hosting/af-hosting/local_telegram/app.py @@ -5,7 +5,7 @@ # "agent-framework-hosting", # "agent-framework-hosting-telegram", # "aiogram>=3.29.1,<4", -# "fastapi>=0.115.0,<0.138.1", +# "fastapi>=0.115.0,<0.142.0", # "hypercorn>=0.17", # ] # /// diff --git a/python/samples/04-hosting/af-hosting/local_telegram/pyproject.toml b/python/samples/04-hosting/af-hosting/local_telegram/pyproject.toml index 525b433bcd4..49e44ce3e3b 100644 --- a/python/samples/04-hosting/af-hosting/local_telegram/pyproject.toml +++ b/python/samples/04-hosting/af-hosting/local_telegram/pyproject.toml @@ -8,7 +8,7 @@ dependencies = [ "agent-framework-hosting", "agent-framework-hosting-telegram", "aiogram>=3.29.1,<4", - "fastapi>=0.115.0,<0.138.1", + "fastapi>=0.115.0,<0.142.0", "hypercorn>=0.17", ] diff --git a/python/uv.lock b/python/uv.lock index 6a3b1a24c00..d07affcd7b9 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -235,7 +235,7 @@ requires-dist = [ { name = "ag-ui-a2ui-toolkit", marker = "extra == 'a2ui'", specifier = ">=0.0.4" }, { name = "ag-ui-protocol", specifier = ">=0.1.19,<0.2" }, { name = "agent-framework-core", editable = "packages/core" }, - { name = "fastapi", specifier = ">=0.121.0,<0.140.0" }, + { name = "fastapi", specifier = ">=0.121.0,<0.142.0" }, { name = "httpx", specifier = ">=0.28.1,<1" }, { name = "pytest", marker = "extra == 'dev'", specifier = "==9.1.1" }, { name = "sse-starlette", specifier = ">=3.4.5,<4" }, @@ -576,7 +576,7 @@ dev = [ requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, { name = "agent-framework-orchestrations", marker = "extra == 'dev'", editable = "packages/orchestrations" }, - { name = "fastapi", specifier = ">=0.115.0,<0.138.1" }, + { name = "fastapi", specifier = ">=0.115.0,<0.142.0" }, { name = "openai", specifier = ">=2.45.0,<4" }, { name = "opentelemetry-sdk", specifier = ">=1.39.0,<2" }, { name = "pytest", marker = "extra == 'all'", specifier = "==9.1.1" }, @@ -767,7 +767,7 @@ requires-dist = [ [package.metadata.requires-dev] test = [ - { name = "fastapi", specifier = ">=0.115.0,<0.138.1" }, + { name = "fastapi", specifier = ">=0.115.0,<0.142.0" }, { name = "httpx", specifier = ">=0.28.1" }, ] @@ -2488,7 +2488,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.138.0" +version = "0.141.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -2497,9 +2497,9 @@ dependencies = [ { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/58/ff455d9fe47c60abadb34b9e05a304b1f05f5ab8000ac01565156b6f5e43/fastapi-0.138.0.tar.gz", hash = "sha256:d445a4877636ad191e7053e08c9bf98cb921a6756776848400bb773d1740c061", size = 419240, upload-time = "2026-06-20T01:18:05.259Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/ff/8496d9847a5fedae775eb49460722d3efaa80487854273e9647ae876218c/fastapi-0.138.0-py3-none-any.whl", hash = "sha256:b6f54fd1bd72c80b0f899f172c61a600f6f7af9b43d4d772a018f35624048cb0", size = 126779, upload-time = "2026-06-20T01:18:03.483Z" }, + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, ] [[package]] From 48365eadd4122d4330701cbddc60d3af071fd402 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 4 Sep 2026 09:36:29 +0000 Subject: [PATCH 18/85] Python: Add core vector store abstractions (#8014) * Python: Add core vector store abstractions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e1edc1-384b-4163-b8bd-f659a510ad1f * Python: Address vector abstraction review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e1edc1-384b-4163-b8bd-f659a510ad1f * Python: Track vector store feature usage Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e1edc1-384b-4163-b8bd-f659a510ad1f * Python: Preserve vector model decorator typing Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e1edc1-384b-4163-b8bd-f659a510ad1f * Python: Refine vector store public API Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e1edc1-384b-4163-b8bd-f659a510ad1f * Refine vector CRUD embedding controls Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e1edc1-384b-4163-b8bd-f659a510ad1f * Clarify vector ownership and generation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e1edc1-384b-4163-b8bd-f659a510ad1f --------- Copilot-Session: 52e1edc1-384b-4163-b8bd-f659a510ad1f --- .github/CODEOWNERS | 1 + .../vector-stores-and-embeddings/README.md | 89 +- docs/specs/feature-usage-bit-registry.md | 3 +- python/packages/core/AGENTS.md | 14 + .../packages/core/agent_framework/__init__.py | 36 + .../core/agent_framework/__init__.pyi | 36 + .../core/agent_framework/_feature_stage.py | 1 + .../core/agent_framework/_telemetry.py | 1 + .../packages/core/agent_framework/_vectors.py | 1923 +++++++++++++++++ .../packages/core/tests/core/test_vectors.py | 1373 ++++++++++++ .../samples/02-agents/vector_stores/README.md | 56 + .../vector_stores/optimized_data_formats.py | 121 ++ .../vector_stores/vector_store_models.py | 174 ++ python/samples/AGENTS.md | 3 +- 14 files changed, 3791 insertions(+), 40 deletions(-) create mode 100644 python/packages/core/agent_framework/_vectors.py create mode 100644 python/packages/core/tests/core/test_vectors.py create mode 100644 python/samples/02-agents/vector_stores/README.md create mode 100644 python/samples/02-agents/vector_stores/optimized_data_formats.py create mode 100644 python/samples/02-agents/vector_stores/vector_store_models.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8f4dc66b86e..3b04ba06be6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -61,6 +61,7 @@ /python/packages/claude/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 /python/packages/copilotstudio/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 /python/packages/core/ @chetantoshniwal @eavanvalkenburg @moonbox3 @TaoChenOSU @jpalvarezl @giles17 +/python/packages/core/agent_framework/_vectors.py @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl @peibekwe @baywet @rogerbarreto @SergeyMenshykh /python/packages/core/agent_framework/_workflows/ @chetantoshniwal @eavanvalkenburg @moonbox3 @TaoChenOSU @jpalvarezl /python/packages/core/agent_framework/_harness/ @chetantoshniwal @westey-m @eavanvalkenburg @moonbox3 /python/packages/declarative/ @chetantoshniwal @eavanvalkenburg @moonbox3 @peibekwe @baywet diff --git a/docs/features/vector-stores-and-embeddings/README.md b/docs/features/vector-stores-and-embeddings/README.md index 9f820ad7c79..15e058fd58a 100644 --- a/docs/features/vector-stores-and-embeddings/README.md +++ b/docs/features/vector-stores-and-embeddings/README.md @@ -10,7 +10,7 @@ This feature ports the vector store abstractions, embedding generator abstractio | Vector store collections | CRUD operations on vector store collections (upsert, get, delete) | | Vector search | Unified search interface with `search_type` parameter (`"vector"`, `"keyword_hybrid"`) | | Data model decorator | `@vectorstoremodel` decorator for defining vector store data models (supports Pydantic, dataclasses, plain classes, dicts) | -| Agent tools | `create_search_tool`, `create_upsert_tool`, `create_get_tool`, `create_delete_tool` for agent-usable vector store operations | +| Agent tools | `create_vector_search_tool`, `create_upsert_tool`, `create_get_tool`, `create_delete_tool` for agent-usable vector store operations | | In-memory store | Zero-dependency vector store for testing and development | | 13+ connectors | Azure AI Search, Qdrant, Redis, PostgreSQL, MongoDB, Cosmos DB, Pinecone, Chroma, Weaviate, Oracle, SQL Server, FAISS | @@ -47,12 +47,12 @@ This feature ports the vector store abstractions, embedding generator abstractio - **Embedding types** (`Embedding`, `GeneratedEmbeddings`, `EmbeddingGenerationOptions`) in `agent_framework/_types.py` - **Embedding protocol + base class** (`SupportsGetEmbeddings`, `BaseEmbeddingClient`) in `agent_framework/_clients.py` - **All vector store specific code** in a new `agent_framework/_vectors.py` module — this includes: - - Enums: `FieldTypes`, `IndexKind`, `DistanceFunction` + - String literal aliases: `FieldTypes`, `IndexKind`, `DistanceFunction` - `VectorStoreField`, `VectorStoreCollectionDefinition` - - `SearchOptions`, `SearchResponse`, `RecordFilterOptions` + - `SearchResponse`, `SearchResults`, and explicit CRUD/search keyword arguments - `@vectorstoremodel` decorator - - Serialization/deserialization protocols - - `VectorStoreRecordHandler`, `BaseVectorCollection`, `BaseVectorStore`, `BaseVectorSearch` + - `register_vectorstoremodel` with msgspec-backed default codecs and optional custom codecs + - Internal record conversion shared by `BaseVectorCollection` and `BaseVectorSearch` - `SupportsVectorUpsert`, `SupportsVectorSearch` protocols - **OpenAI embeddings** in `agent_framework/openai/` (built into core, like OpenAI chat) - **Azure OpenAI embeddings** in `agent_framework/azure/` (built into core, follows `AzureOpenAIChatClient` pattern) @@ -69,9 +69,9 @@ This feature ports the vector store abstractions, embedding generator abstractio | `VectorStoreCollection` | `BaseVectorCollection` | Drop redundant `Store`, add `Base` prefix per AF pattern | | `VectorStore` | `BaseVectorStore` | Add `Base` prefix per AF pattern | | `VectorSearch` | `BaseVectorSearch` | Add `Base` prefix per AF pattern | -| `VectorSearchOptions` | `SearchOptions` | Shorter — context is already vector search | +| `VectorSearchOptions` | Explicit `search()` keyword arguments | Avoid an options object that only forwards values | | `VectorSearchResult` | `SearchResponse` | Align with `ChatResponse`/`AgentResponse` | -| `GetFilteredRecordOptions` | `RecordFilterOptions` | Shorter, more natural | +| `GetFilteredRecordOptions` | Explicit `get()` keyword arguments | Avoid an options object that only forwards values | | `EmbeddingGeneratorBase` | `BaseEmbeddingClient` | Matches AF `BaseChatClient` pattern | | `VectorStoreCollectionProtocol` | `SupportsVectorUpsert` | AF `Supports*` naming convention | | `VectorSearchProtocol` | `SupportsVectorSearch` | AF `Supports*` naming convention | @@ -88,7 +88,6 @@ This feature ports the vector store abstractions, embedding generator abstractio | `@vectorstoremodel` | `_vectors.py` | | `VectorStoreField` | `_vectors.py` | | `VectorStoreCollectionDefinition` | `_vectors.py` | -| `VectorStoreRecordHandler` | `_vectors.py` | | `FieldTypes` | `_vectors.py` | | `IndexKind` | `_vectors.py` | | `DistanceFunction` | `_vectors.py` | @@ -107,7 +106,7 @@ This feature ports the vector store abstractions, embedding generator abstractio | `EmbeddingTelemetryLayer` | `observability.py` | MRO-based OTel tracing for embeddings | | `SupportsVectorUpsert` | `_vectors.py` | Protocol for collection CRUD | | `SupportsVectorSearch` | `_vectors.py` | Protocol for vector search | -| `create_search_tool` | `_vectors.py` | Creates AF `FunctionTool` from vector search | +| `create_vector_search_tool` | `_vectors.py` | Creates AF `FunctionTool` from vector search | ## Source Files Reference (SK → AF mapping) @@ -187,41 +186,52 @@ This feature ports the vector store abstractions, embedding generator abstractio ### Phase 3: Core Vector Store Abstractions **Goal:** Establish all vector store types, enums, the decorator, collection definition, and base classes. **Mergeable:** Yes — adds new abstractions, no breaking changes. +**Feature stage:** Experimental (`VECTOR_STORES`). -#### 3.1 — Vector store enums and field types in `_vectors.py` -- `FieldTypes` enum: `KEY`, `VECTOR`, `DATA` -- `IndexKind` enum: `HNSW`, `FLAT`, `IVF_FLAT`, `DISK_ANN`, `QUANTIZED_FLAT`, `DYNAMIC`, `DEFAULT` -- `DistanceFunction` enum: `COSINE_SIMILARITY`, `COSINE_DISTANCE`, `DOT_PROD`, `EUCLIDEAN_DISTANCE`, `EUCLIDEAN_SQUARED_DISTANCE`, `MANHATTAN`, `HAMMING`, `DEFAULT` -- No `SearchType` enum — use `Literal["vector", "keyword_hybrid"]` instead, per AF convention of avoiding unnecessary imports +#### 3.1 — Vector store literal aliases and field types in `_vectors.py` +- `FieldTypes`: `Literal["key", "vector", "data"]` +- `IndexKind`: literal alias covering `hnsw`, `flat`, `ivf_flat`, `disk_ann`, `quantized_flat`, `dynamic`, and `default` +- `DistanceFunction`: literal alias covering the supported similarity and distance functions +- `SearchType`: `Literal["vector", "keyword_hybrid"]` - `VectorStoreField` plain class (not Pydantic) - `VectorStoreCollectionDefinition` class (not Pydantic internally, but supports Pydantic models as input) -- `SearchOptions` plain class — includes `score_threshold: float | None` for filtering results by score (see note below) - `SearchResponse` generic class -- `RecordFilterOptions` plain class +- `SearchResults` generic result container +- Explicit keyword arguments on `get()` and `search()` instead of options classes - `DISTANCE_FUNCTION_DIRECTION_HELPER` dict #### 3.2 — `@vectorstoremodel` decorator - Port from SK, works with dataclasses, Pydantic models, plain classes, and dicts +- Plain classes can declare `VectorStoreField` metadata on annotated `__init__` parameters, matching `@tool` - Sets `__vectorstoremodel__` and `__vectorstoremodel_definition__` on the class - Remove SK-specific `kernel` prefix (`__kernel_vectorstoremodel__` → `__vectorstoremodel__`) -#### 3.3 — Serialization/deserialization protocols -- `SerializeMethodProtocol`, `ToDictFunctionProtocol`, `FromDictFunctionProtocol`, etc. -- Port the record handler logic but without Pydantic base class — use plain class or ABC +#### 3.3 — Registered model codecs +- `register_vectorstoremodel` registers one collection definition and encoder/decoder pair per model type +- `@vectorstoremodel` creates the definition and registers msgspec-backed default codecs +- Dictionary records provide their collection definition directly +- DataFrames and other row containers convert to sequences of row mappings before using the batch API +- Custom encoder and decoder callbacks can be overridden independently +- Array-like values such as NumPy arrays serialize through their `tolist()` method without a NumPy dependency; + supply a custom decoder that calls `numpy.array` or `numpy.asarray` when the model should restore an array #### 3.4 — Vector store base classes in `_vectors.py` -- `VectorStoreRecordHandler` — internal base class that handles serialization/deserialization between user data models and store-specific formats, plus embedding generation for vector fields. Both `BaseVectorCollection` and `BaseVectorSearch` extend this. -- `BaseVectorCollection(VectorStoreRecordHandler)` — base for collections +- `_VectorStoreRecordHandler` — private base class that handles record conversion and embedding generation +- `BaseVectorCollection` — base for collections - Uses `SupportsGetEmbeddings` instead of `EmbeddingGeneratorBase` - Not a Pydantic model — use `__init__` with explicit params - - `upsert`, `get`, `delete`, `ensure_collection_exists`, `collection_exists`, `ensure_collection_deleted` + - Batch-oriented `upsert`, `get`, and `delete` + - `upsert()` generates vector values by default and requires an embedding generator for every vector field; + pass `generate_vectors=False` to preserve supplied vector values + - CRUD `get()` excludes vectors by default; pass `include_vectors=True` when stored embeddings are needed + - `ensure_collection_exists`, `collection_exists`, `ensure_collection_deleted` - Async context manager support - `BaseVectorStore` — base for stores - `get_collection`, `list_collection_names`, `collection_exists`, `ensure_collection_deleted` - Async context manager support #### 3.5 — Vector search base class -- `BaseVectorSearch(VectorStoreRecordHandler)` — base for vector search +- `BaseVectorSearch` — base for vector search - Single `search(search_type=...)` method with `search_type: Literal["vector", "keyword_hybrid"]` parameter — no enum, just a literal - `_inner_search` abstract method for implementations - Filter building with lambda parser (AST-based) @@ -234,14 +244,17 @@ This feature ports the vector store abstractions, embedding generator abstractio - No protocol for `VectorStore` — it's a factory for collections, not a capability to duck-type against #### 3.7 — Exception types -- Add vector store exceptions under `IntegrationException` or create new branch -- `VectorStoreException`, `VectorStoreOperationException`, `VectorSearchException`, `VectorStoreModelException`, etc. +- Use `ValueError` and `TypeError` for invalid arguments, model definitions, and record conversion +- Use `NotImplementedError` for connector capabilities that are not supported +- Use the existing `IntegrationException` and `IntegrationInvalidResponseException` at connector boundaries -#### 3.8 — `create_search_tool` on `BaseVectorSearch` -- Method on `BaseVectorSearch` that creates an AF `FunctionTool` from the vector search +#### 3.8 — `create_vector_search_tool` +- Standalone factory that creates an AF `FunctionTool` from any `SupportsVectorSearch` implementation - Wraps the single `search()` method, passing `search_type` parameter -- Accepts: `name`, `description`, `search_type`, `top`, `skip`, `filter`, `string_mapper` -- The tool takes a query string, vectorizes it, searches, and returns results as strings +- Accepts: `name`, `description`, `approval_mode`, `search_type`, `parameters`, `top`, `skip`, `filter`, `filter_mapper`, `result_mapper` +- Defaults to `query`; a custom Pydantic model or JSON schema can expose `top`, `skip`, and additional filter fields +- Custom schemas must require a string `query`; exposed `top` and `skip` fields must declare finite maximum values +- The tool vectorizes the query, searches, and maps results to text or multimodal `Content` - Can also be a standalone factory function in `_vectors.py` #### 3.9 — Tests for all vector store abstractions @@ -326,7 +339,7 @@ Each connector follows the AF package structure: #### 8.1 — `create_upsert_tool` — tool for upserting records into a collection #### 8.2 — `create_get_tool` — tool for retrieving records by key - Key-based lookup only (by primary key), not a search tool -- Documentation must clearly distinguish this from `create_search_tool`: get_tool retrieves specific records by their known key, while search_tool performs similarity/filtered search across the collection +- Documentation must clearly distinguish this from `create_vector_search_tool`: get_tool retrieves specific records by their known key, while the search tool performs similarity/filtered search across the collection - Consider if this overlaps with filtered search and document when to use which #### 8.3 — `create_delete_tool` — tool for deleting records by key #### 8.4 — Tests and samples for CRUD tools @@ -349,7 +362,7 @@ Each connector follows the AF package structure: **Mergeable:** Yes — independent of vector stores. #### 10.1 — TextSearch base class and types -- `SearchOptions`, `SearchResponse`, `TextSearchResult` +- `SearchResponse`, `TextSearchResult`, and explicit search keyword arguments - `TextSearch` base class with `search()` method - `create_search_function()` for kernel integration (may need AF equivalent) @@ -361,7 +374,7 @@ Each connector follows the AF package structure: ## Key Considerations -1. **No Pydantic for internal classes**: All AF internal classes should use plain classes. Pydantic is only used for user-facing input validation (e.g., vector store data models). +1. **msgspec-backed conversion**: Use msgspec as the default serialization/deserialization path. Pydantic and plain classes remain supported user-model adapters. 2. **Protocol + Base class**: Follow AF's pattern of both a `Protocol` for duck-typing and a `Base` ABC for implementation, matching how `SupportsChatGetResponse` + `BaseChatClient` works. @@ -375,11 +388,11 @@ Each connector follows the AF package structure: 7. **Reusable data models**: The `@vectorstoremodel` decorator and `VectorStoreCollectionDefinition` should be agnostic enough to work with both SK and AF. The core types (`FieldTypes`, `IndexKind`, `DistanceFunction`, `VectorStoreField`) should be identical or easily mapped. -8. **`create_search_tool`**: The AF-native equivalent of SK's `create_search_function`. Instead of creating a `KernelFunction`, this creates an AF `FunctionTool` (via the `@tool` decorator pattern) from a vector search. This allows agents to use vector search as a tool during conversations. Design: - - `create_search_tool(name, description, search_type, ...)` → returns a `FunctionTool` that wraps `VectorSearch.search(search_type=...)` - - The tool accepts a query string, performs embedding + vector search, and returns results as strings - - Supports configurable string mappers, filter functions, top/skip defaults - - Lives in `_vectors.py` as a method on `BaseVectorSearch` and/or as a standalone factory function +8. **`create_vector_search_tool`**: The AF-native equivalent of SK's `create_search_function`. Instead of creating a `KernelFunction`, this creates an AF `FunctionTool` from any `SupportsVectorSearch` implementation. This allows agents to use vector search as a tool during conversations. Design: + - `create_vector_search_tool(search, name, description, search_type, ...)` returns a `FunctionTool` + - The tool accepts declared parameters, performs embedding + vector search, and returns text or multimodal content + - Defaults to `query`; custom parameters can expose `top`, `skip`, and additional fields for the filter mapper + - Lives in `_vectors.py` without expanding the structural search protocol 9. **CRUD tools**: A full set of create/read/update/delete tools for vector store collections, allowing agents to manage data in vector stores. Design: - `create_upsert_tool(...)` → tool for upserting records @@ -387,4 +400,4 @@ Each connector follows the AF package structure: - `create_delete_tool(...)` → tool for deleting records - These are separate from search and are placed in a later phase -10. **Score threshold filtering**: `SearchOptions` includes `score_threshold: float | None` to filter search results by relevance score (ref: [SK .NET PR #13501](https://github.com/microsoft/semantic-kernel/pull/13501)). The semantics depend on the distance function: for similarity functions (cosine similarity, dot product), results *below* the threshold are filtered out; for distance functions (cosine distance, euclidean), results *above* the threshold are filtered out. Use `DISTANCE_FUNCTION_DIRECTION_HELPER` to determine direction. Connectors should implement this natively where the database supports it, falling back to client-side post-filtering otherwise. +10. **Score threshold filtering**: `search(score_threshold=...)` filters results by relevance score (ref: [SK .NET PR #13501](https://github.com/microsoft/semantic-kernel/pull/13501)). The semantics depend on the distance function: for similarity functions (cosine similarity, dot product), results *below* the threshold are filtered out; for distance functions (cosine distance, euclidean), results *above* the threshold are filtered out. Use `DISTANCE_FUNCTION_DIRECTION_HELPER` to determine direction. Connectors should implement this natively where the database supports it, falling back to client-side post-filtering otherwise. diff --git a/docs/specs/feature-usage-bit-registry.md b/docs/specs/feature-usage-bit-registry.md index 6b89e4988fb..1e955610acf 100644 --- a/docs/specs/feature-usage-bit-registry.md +++ b/docs/specs/feature-usage-bit-registry.md @@ -137,7 +137,8 @@ only to approved first-party endpoints. | 16 | `core.mcp_skills_source` | MCP-backed skills | `agent_framework.MCPSkillsSource` | | 17 | `core.session_store` | Agent session store | `agent_framework.SessionStore` / `FileSessionStore` | | 18 | `core.agent_hooks` | Agent Hooks middleware | `agent_framework.create_agent_hooks_middleware` | -| 19–31 | _reserved_ | core growth | — | +| 19 | `core.vector_stores` | Vector store abstractions | `BaseVectorCollection` / `BaseVectorSearch` operations | +| 20–31 | _reserved_ | core growth | — | | 32 | `orchestration.sequential` | Sequential orchestration | `agent_framework_orchestrations.SequentialBuilder` | | 33 | `orchestration.concurrent` | Concurrent orchestration | `agent_framework_orchestrations.ConcurrentBuilder` | | 34 | `orchestration.group_chat` | Group-chat orchestration | `agent_framework_orchestrations.GroupChatBuilder` | diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index 288e07a2a6e..7182615705d 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -13,6 +13,7 @@ agent_framework/ ├── _clients.py # Chat client base classes and protocols ├── _types.py # Core types (Message, ChatResponse, Content, etc.) ├── _tools.py # Tool definitions and function invocation +├── _vectors.py # Vector store models, CRUD/search abstractions, and protocols ├── _middleware.py # Middleware system for request/response interception ├── _sessions.py # AgentSession and context provider abstractions ├── _skills.py # Agent Skills system (models, executors, provider) @@ -64,6 +65,19 @@ agent_framework/ - **`@tool`** decorator - Converts functions to tools - **`use_function_invocation()`** - Decorator to add automatic function calling to chat clients +### Vector stores (`_vectors.py`) + +The vector store API is experimental under the shared `VECTOR_STORES` feature ID. + +- **`@vectorstoremodel`** - Declares key, data, and vector fields on dataclasses, Pydantic models, and plain classes +- **`register_vectorstoremodel`** - Registers one definition and msgspec-backed codec pair per model type +- **`BaseVectorCollection`** - Base class for collection lifecycle and msgspec-backed record CRUD operations; + upserts generate embeddings by default and retrieval excludes vectors by default +- **`BaseVectorStore`** - Base class for stores that create collection clients +- **`BaseVectorSearch`** - Base class for vector and keyword-hybrid search +- **`create_vector_search_tool`** - Creates an agent tool from any `SupportsVectorSearch` implementation +- **`SupportsVectorUpsert`** / **`SupportsVectorSearch`** - Structural protocols for vector store capabilities + ### Middleware (`_middleware.py`) - **`AgentMiddleware`** - Intercepts agent `run()` calls diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index 77c56fa7559..241bcda3aae 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -284,6 +284,25 @@ "validate_tool_mode", "validate_tools", ), + "._vectors": ( + "DISTANCE_FUNCTION_DIRECTION_HELPER", + "BaseVectorCollection", + "BaseVectorSearch", + "BaseVectorStore", + "DistanceFunction", + "FieldTypes", + "IndexKind", + "SearchResponse", + "SearchResults", + "SearchType", + "SupportsVectorSearch", + "SupportsVectorUpsert", + "VectorStoreCollectionDefinition", + "VectorStoreField", + "create_vector_search_tool", + "register_vectorstoremodel", + "vectorstoremodel", + ), "._workflows._agent": ("WorkflowAgent",), "._workflows._agent_executor": ("AgentExecutor", "AgentExecutorRequest", "AgentExecutorResponse"), "._workflows._agent_utils": ("resolve_agent_id",), @@ -371,6 +390,7 @@ "DEFAULT_MODE_SOURCE_ID", "DEFAULT_TODO_SOURCE_ID", "DEFAULT_TOOL_APPROVAL_SOURCE_ID", + "DISTANCE_FUNCTION_DIRECTION_HELPER", "EXCLUDED_KEY", "EXCLUDE_REASON_KEY", "GROUP_ANNOTATION_KEY", @@ -412,6 +432,9 @@ "BaseAgent", "BaseChatClient", "BaseEmbeddingClient", + "BaseVectorCollection", + "BaseVectorSearch", + "BaseVectorStore", "CachingSkillsSource", "Case", "CharacterEstimatorTokenizer", @@ -438,6 +461,7 @@ "DeduplicatingSkillsSource", "Default", "DelegatingSkillsSource", + "DistanceFunction", "Edge", "EdgeCondition", "EdgeDuplicationError", @@ -456,6 +480,7 @@ "ExperimentalFeature", "FanInEdgeGroup", "FanOutEdgeGroup", + "FieldTypes", "FileAccessProvider", "FileCheckpointStorage", "FileHistoryProvider", @@ -490,6 +515,7 @@ "InMemoryHistoryProvider", "InMemorySkillsSource", "InProcRunnerContext", + "IndexKind", "InlineSkill", "InlineSkillResource", "InlineSkillScript", @@ -527,6 +553,9 @@ "Runner", "RunnerContext", "SamplingApprovalCallback", + "SearchResponse", + "SearchResults", + "SearchType", "SecretString", "SelectiveToolCallCompactionStrategy", "ServiceSessionId", @@ -555,6 +584,8 @@ "SupportsImageGenerationTool", "SupportsMCPTool", "SupportsShellTool", + "SupportsVectorSearch", + "SupportsVectorUpsert", "SupportsWebSearchTool", "SwitchCaseEdgeGroup", "SwitchCaseEdgeGroupCase", @@ -580,6 +611,8 @@ "UsageDetails", "UserInputRequiredException", "ValidationTypeEnum", + "VectorStoreCollectionDefinition", + "VectorStoreField", "Workflow", "WorkflowAgent", "WorkflowBuilder", @@ -613,6 +646,7 @@ "create_always_approve_tool_with_arguments_response", "create_edge_runner", "create_harness_agent", + "create_vector_search_tool", "detect_media_type_from_base64", "enqueue_messages", "evaluate_agent", @@ -636,6 +670,7 @@ "prepend_instructions_to_messages", "register_checkpoint_type", "register_state_type", + "register_vectorstoremodel", "resolve_agent_id", "response_handler", "set_agent_mode", @@ -650,6 +685,7 @@ "validate_tool_mode", "validate_tools", "validate_workflow_graph", + "vectorstoremodel", "workflow", ] diff --git a/python/packages/core/agent_framework/__init__.pyi b/python/packages/core/agent_framework/__init__.pyi index fa8f6a75ae6..5816c90600f 100644 --- a/python/packages/core/agent_framework/__init__.pyi +++ b/python/packages/core/agent_framework/__init__.pyi @@ -250,6 +250,25 @@ from ._types import ( validate_tool_mode, validate_tools, ) +from ._vectors import ( + DISTANCE_FUNCTION_DIRECTION_HELPER, + BaseVectorCollection, + BaseVectorSearch, + BaseVectorStore, + DistanceFunction, + FieldTypes, + IndexKind, + SearchResponse, + SearchResults, + SearchType, + SupportsVectorSearch, + SupportsVectorUpsert, + VectorStoreCollectionDefinition, + VectorStoreField, + create_vector_search_tool, + register_vectorstoremodel, + vectorstoremodel, +) from ._workflows._agent import WorkflowAgent from ._workflows._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse from ._workflows._agent_utils import resolve_agent_id @@ -335,6 +354,7 @@ __all__ = [ "DEFAULT_MODE_SOURCE_ID", "DEFAULT_TODO_SOURCE_ID", "DEFAULT_TOOL_APPROVAL_SOURCE_ID", + "DISTANCE_FUNCTION_DIRECTION_HELPER", "EXCLUDED_KEY", "EXCLUDE_REASON_KEY", "GROUP_ANNOTATION_KEY", @@ -376,6 +396,9 @@ __all__ = [ "BaseAgent", "BaseChatClient", "BaseEmbeddingClient", + "BaseVectorCollection", + "BaseVectorSearch", + "BaseVectorStore", "CachingSkillsSource", "Case", "CharacterEstimatorTokenizer", @@ -402,6 +425,7 @@ __all__ = [ "DeduplicatingSkillsSource", "Default", "DelegatingSkillsSource", + "DistanceFunction", "Edge", "EdgeCondition", "EdgeDuplicationError", @@ -420,6 +444,7 @@ __all__ = [ "ExperimentalFeature", "FanInEdgeGroup", "FanOutEdgeGroup", + "FieldTypes", "FileAccessProvider", "FileCheckpointStorage", "FileHistoryProvider", @@ -454,6 +479,7 @@ __all__ = [ "InMemoryHistoryProvider", "InMemorySkillsSource", "InProcRunnerContext", + "IndexKind", "InlineSkill", "InlineSkillResource", "InlineSkillScript", @@ -491,6 +517,9 @@ __all__ = [ "Runner", "RunnerContext", "SamplingApprovalCallback", + "SearchResponse", + "SearchResults", + "SearchType", "SecretString", "SelectiveToolCallCompactionStrategy", "ServiceSessionId", @@ -519,6 +548,8 @@ __all__ = [ "SupportsImageGenerationTool", "SupportsMCPTool", "SupportsShellTool", + "SupportsVectorSearch", + "SupportsVectorUpsert", "SupportsWebSearchTool", "SwitchCaseEdgeGroup", "SwitchCaseEdgeGroupCase", @@ -544,6 +575,8 @@ __all__ = [ "UsageDetails", "UserInputRequiredException", "ValidationTypeEnum", + "VectorStoreCollectionDefinition", + "VectorStoreField", "Workflow", "WorkflowAgent", "WorkflowBuilder", @@ -577,6 +610,7 @@ __all__ = [ "create_always_approve_tool_with_arguments_response", "create_edge_runner", "create_harness_agent", + "create_vector_search_tool", "detect_media_type_from_base64", "enqueue_messages", "evaluate_agent", @@ -600,6 +634,7 @@ __all__ = [ "prepend_instructions_to_messages", "register_checkpoint_type", "register_state_type", + "register_vectorstoremodel", "resolve_agent_id", "response_handler", "set_agent_mode", @@ -614,5 +649,6 @@ __all__ = [ "validate_tool_mode", "validate_tools", "validate_workflow_graph", + "vectorstoremodel", "workflow", ] diff --git a/python/packages/core/agent_framework/_feature_stage.py b/python/packages/core/agent_framework/_feature_stage.py index 53893e397f3..9f694f6423a 100644 --- a/python/packages/core/agent_framework/_feature_stage.py +++ b/python/packages/core/agent_framework/_feature_stage.py @@ -64,6 +64,7 @@ class ExperimentalFeature(str, Enum): PROGRESSIVE_TOOLS = "PROGRESSIVE_TOOLS" SESSION_STORE = "SESSION_STORE" TO_PROMPT_AGENT = "TO_PROMPT_AGENT" + VECTOR_STORES = "VECTOR_STORES" class ReleaseCandidateFeature(str, Enum): diff --git a/python/packages/core/agent_framework/_telemetry.py b/python/packages/core/agent_framework/_telemetry.py index 955e276fe2d..d20f31b86cf 100644 --- a/python/packages/core/agent_framework/_telemetry.py +++ b/python/packages/core/agent_framework/_telemetry.py @@ -55,6 +55,7 @@ class FeatureIndex(IntEnum): CORE_MCP_SKILLS_SOURCE = 16 CORE_SESSION_STORE = 17 CORE_AGENT_HOOKS = 18 + CORE_VECTOR_STORES = 19 # This environment variable is reserved by the Foundry hosting environment to diff --git a/python/packages/core/agent_framework/_vectors.py b/python/packages/core/agent_framework/_vectors.py new file mode 100644 index 00000000000..761cc3a5bb2 --- /dev/null +++ b/python/packages/core/agent_framework/_vectors.py @@ -0,0 +1,1923 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Core vector store abstractions.""" + +from __future__ import annotations + +import operator +from abc import ABC, abstractmethod +from ast import AST, Lambda, NodeVisitor, expr, parse +from collections.abc import AsyncIterable, AsyncIterator, Callable, Mapping, Sequence +from dataclasses import dataclass, is_dataclass, replace +from inspect import Parameter, getsource, signature +from types import UnionType +from typing import ( + Annotated, + Any, + ClassVar, + Final, + Generic, + Literal, + Protocol, + TypeAlias, + TypeGuard, + Union, + cast, + get_args, + get_origin, + get_type_hints, + overload, + runtime_checkable, +) + +import msgspec +from pydantic import BaseModel +from typing_extensions import Self, TypedDict, TypeVar + +from ._clients import SupportsGetEmbeddings +from ._feature_stage import ExperimentalFeature, experimental +from ._telemetry import FeatureIndex, mark_feature_used +from ._tools import FunctionTool +from ._types import Content, EmbeddingGenerationOptions +from .exceptions import IntegrationException, IntegrationInvalidResponseException + +ModelT = TypeVar("ModelT", default=Any) +KeyT = TypeVar("KeyT", default=Any) +FilterT = TypeVar("FilterT") +ResultT = TypeVar("ResultT") +DecoratedModelT = TypeVar("DecoratedModelT") + +SearchType: TypeAlias = Literal["vector", "keyword_hybrid"] +FieldTypes: TypeAlias = Literal["key", "vector", "data"] +IndexKind: TypeAlias = Literal["hnsw", "flat", "ivf_flat", "disk_ann", "quantized_flat", "dynamic", "default"] +DistanceFunction: TypeAlias = Literal[ + "cosine_similarity", + "cosine_distance", + "dot_prod", + "euclidean_distance", + "euclidean_squared_distance", + "manhattan", + "hamming", + "DEFAULT", +] +Vector: TypeAlias = Sequence[float | int] +RecordFilter: TypeAlias = Callable[[Any], bool] | str +RecordFilters: TypeAlias = RecordFilter | Sequence[RecordFilter] +EmbeddingClient: TypeAlias = SupportsGetEmbeddings[Any, Any, Any] +VectorModelEncoder: TypeAlias = Callable[[Any], Mapping[str, Any]] +VectorModelDecoder: TypeAlias = Callable[[Mapping[str, Any]], Any] + +_DEFAULT_SEARCH_TOOL_NAME: Final[str] = "search" +_DEFAULT_SEARCH_TOOL_DESCRIPTION: Final[str] = ( + "Perform a vector search for data in a vector store using the provided query." +) +_INDEX_KINDS: Final[tuple[str, ...]] = ( + "hnsw", + "flat", + "ivf_flat", + "disk_ann", + "quantized_flat", + "dynamic", + "default", +) +_DISTANCE_FUNCTIONS: Final[tuple[str, ...]] = ( + "cosine_similarity", + "cosine_distance", + "dot_prod", + "euclidean_distance", + "euclidean_squared_distance", + "manhattan", + "hamming", + "DEFAULT", +) + + +DISTANCE_FUNCTION_DIRECTION_HELPER: Final[Mapping[DistanceFunction, Callable[[float | int, float | int], bool]]] = { + "cosine_similarity": operator.ge, + "cosine_distance": operator.le, + "dot_prod": operator.ge, + "euclidean_distance": operator.le, + "euclidean_squared_distance": operator.le, + "manhattan": operator.le, + "hamming": operator.le, +} + + +def _msgspec_enc_hook(value: Any) -> Any: + if isinstance(value, BaseModel): + return value.model_dump() + to_list = getattr(value, "tolist", None) + if callable(to_list): + return to_list() + if hasattr(value, "__dict__"): + return cast(dict[str, Any], vars(value)) + raise NotImplementedError(f"Objects of type {type(value).__name__!r} are not supported.") + + +def _normalize_vector(value: Any) -> Vector: + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return cast(Vector, value) + to_list = getattr(value, "tolist", None) + if callable(to_list): + converted = to_list() + if isinstance(converted, Sequence) and not isinstance(converted, (str, bytes, bytearray)): + return cast(Vector, converted) + raise TypeError("The embedding client returned an unsupported vector type.") + + +@experimental(feature_id=ExperimentalFeature.VECTOR_STORES) +@dataclass(frozen=True, slots=True, init=False) +class VectorStoreField: + """Describe one field in a vector store model.""" + + field_type: FieldTypes + name: str + type_: str | None + storage_name: str | None + is_indexed: bool | None + is_full_text_indexed: bool | None + dimensions: int | None + index_kind: IndexKind | None + distance_function: DistanceFunction | None + embedding_generator: EmbeddingClient | None + + @overload + def __init__( + self, + field_type: Literal["key"], + *, + name: str | None = None, + type_: str | None = None, + storage_name: str | None = None, + ) -> None: + """Initialize a key field. + + Args: + field_type: The key field type. + name: The model field name. The decorator supplies this when omitted. + type_: The scalar type name used by the backing store. + storage_name: The field name used by the backing store. + """ + ... + + @overload + def __init__( + self, + field_type: Literal["data"] = "data", + *, + name: str | None = None, + type_: str | None = None, + storage_name: str | None = None, + is_indexed: bool | None = None, + is_full_text_indexed: bool | None = None, + ) -> None: + """Initialize a data field with optional indexing. + + Args: + field_type: The data field type. + name: The model field name. The decorator supplies this when omitted. + type_: The scalar type name used by the backing store. + storage_name: The field name used by the backing store. + is_indexed: Whether the field should be indexed. + is_full_text_indexed: Whether the field should have a full-text index. + """ + ... + + @overload + def __init__( + self, + field_type: Literal["vector"], + *, + name: str | None = None, + type_: str | None = None, + storage_name: str | None = None, + dimensions: int, + index_kind: IndexKind | None = None, + distance_function: DistanceFunction | None = None, + embedding_generator: EmbeddingClient | None = None, + ) -> None: + """Initialize a vector field with required dimensions. + + Args: + field_type: The vector field type. + name: The model field name. The decorator supplies this when omitted. + type_: The vector element type name used by the backing store. + storage_name: The field name used by the backing store. + dimensions: The number of vector dimensions. + index_kind: The vector index kind. + distance_function: The vector distance function. + embedding_generator: An optional client used to generate this field's embeddings. + + Raises: + ValueError: If dimensions or vector options are invalid. + """ + ... + + def __init__( + self, + field_type: FieldTypes = "data", + *, + name: str | None = None, + type_: str | None = None, + storage_name: str | None = None, + is_indexed: bool | None = None, + is_full_text_indexed: bool | None = None, + dimensions: int | None = None, + index_kind: IndexKind | None = None, + distance_function: DistanceFunction | None = None, + embedding_generator: EmbeddingClient | None = None, + ) -> None: + """Initialize a vector store field. + + Args: + field_type: The field's role in the vector store model. + name: The model field name. The decorator supplies this when omitted. + type_: The scalar type name used by the backing store. + storage_name: The field name used by the backing store. + is_indexed: Whether a data field should be indexed. + is_full_text_indexed: Whether a data field should have a full-text index. + dimensions: The number of vector dimensions. Required for vector fields. + index_kind: The vector index kind. + distance_function: The vector distance function. + embedding_generator: An optional client used to generate this field's embeddings. + + Raises: + ValueError: If field options are invalid. + """ + if field_type not in ("key", "vector", "data"): + raise ValueError(f"Unknown vector store field type '{field_type}'.") + resolved_dimensions: int | None = None + resolved_index_kind: IndexKind | None = None + resolved_distance_function: DistanceFunction | None = None + resolved_embedding_generator: EmbeddingClient | None = None + if field_type == "vector": + if dimensions is None or dimensions <= 0: + raise ValueError("Vector fields must specify a positive number of dimensions.") + if index_kind is not None and index_kind not in _INDEX_KINDS: + raise ValueError(f"Unknown vector index kind '{index_kind}'.") + if distance_function is not None and distance_function not in _DISTANCE_FUNCTIONS: + raise ValueError(f"Unknown vector distance function '{distance_function}'.") + resolved_dimensions = dimensions + resolved_index_kind = index_kind or "default" + resolved_distance_function = distance_function or "DEFAULT" + resolved_embedding_generator = embedding_generator + elif any(value is not None for value in (dimensions, index_kind, distance_function, embedding_generator)): + raise ValueError("Vector-only options can only be set on vector fields.") + + object.__setattr__(self, "field_type", field_type) + object.__setattr__(self, "name", name or "") + object.__setattr__(self, "type_", type_) + object.__setattr__(self, "storage_name", storage_name) + object.__setattr__(self, "is_indexed", is_indexed) + object.__setattr__(self, "is_full_text_indexed", is_full_text_indexed) + object.__setattr__(self, "dimensions", resolved_dimensions) + object.__setattr__(self, "index_kind", resolved_index_kind) + object.__setattr__(self, "distance_function", resolved_distance_function) + object.__setattr__(self, "embedding_generator", resolved_embedding_generator) + + +@experimental(feature_id=ExperimentalFeature.VECTOR_STORES) +@dataclass(frozen=True, slots=True, init=False) +class VectorStoreCollectionDefinition: + """Describe the records stored in a vector collection. + + Most users should not create this class directly. Applying + :func:`vectorstoremodel` to a typed model derives and registers its + collection definition automatically. + + Create a definition explicitly for schema-less records such as dictionaries, + or when adapting an externally owned model through + :func:`register_vectorstoremodel`. + """ + + fields: tuple[VectorStoreField, ...] + collection_name: str | None + key_name: str + + def __init__( + self, + fields: Sequence[VectorStoreField], + *, + collection_name: str | None = None, + ) -> None: + """Initialize a vector store collection definition. + + Args: + fields: The key, data, and vector fields in each record. + collection_name: The collection name associated with the model. + + Raises: + ValueError: If field names or key fields are invalid. + """ + object.__setattr__(self, "fields", tuple(fields)) + object.__setattr__(self, "collection_name", collection_name) + object.__setattr__(self, "key_name", self._validate()) + + def _validate(self) -> str: + if not self.fields: + raise ValueError("A vector store definition must contain at least one field.") + if any(not field.name for field in self.fields): + raise ValueError("Vector store field names must not be empty.") + + names = [field.name for field in self.fields] + if len(names) != len(set(names)): + raise ValueError("Vector store field names must be unique.") + storage_names = [field.storage_name or field.name for field in self.fields] + if len(storage_names) != len(set(storage_names)): + raise ValueError("Vector store field storage names must be unique.") + + key_fields = [field for field in self.fields if field.field_type == "key"] + if len(key_fields) != 1: + raise ValueError("A vector store definition must contain exactly one key field.") + return key_fields[0].name + + @property + def names(self) -> list[str]: + """Get the model field names.""" + return [field.name for field in self.fields] + + @property + def storage_names(self) -> list[str]: + """Get the backing store field names.""" + return [field.storage_name or field.name for field in self.fields] + + @property + def key_field(self) -> VectorStoreField: + """Get the key field.""" + return next(field for field in self.fields if field.field_type == "key") + + @property + def key_field_storage_name(self) -> str: + """Get the key field's backing store name.""" + return self.key_field.storage_name or self.key_field.name + + @property + def vector_fields(self) -> list[VectorStoreField]: + """Get the vector fields.""" + return [field for field in self.fields if field.field_type == "vector"] + + @property + def data_fields(self) -> list[VectorStoreField]: + """Get the data fields.""" + return [field for field in self.fields if field.field_type == "data"] + + @property + def vector_field_names(self) -> list[str]: + """Get the vector field names.""" + return [field.name for field in self.vector_fields] + + @property + def data_field_names(self) -> list[str]: + """Get the data field names.""" + return [field.name for field in self.data_fields] + + def try_get_vector_field(self, field_name: str | None = None) -> VectorStoreField | None: + """Get a vector field by model or storage name, defaulting to the first vector field.""" + if field_name is None: + return self.vector_fields[0] if self.vector_fields else None + return next( + (field for field in self.vector_fields if field.name == field_name or field.storage_name == field_name), + None, + ) + + def get_names(self, *, include_vector_fields: bool = True, include_key_field: bool = True) -> list[str]: + """Get selected model field names.""" + return [ + field.name + for field in self.fields + if field.field_type == "data" + or (field.field_type == "vector" and include_vector_fields) + or (field.field_type == "key" and include_key_field) + ] + + def get_storage_names(self, *, include_vector_fields: bool = True, include_key_field: bool = True) -> list[str]: + """Get selected backing store field names.""" + return [ + field.storage_name or field.name + for field in self.fields + if field.field_type == "data" + or (field.field_type == "vector" and include_vector_fields) + or (field.field_type == "key" and include_key_field) + ] + + +@dataclass(frozen=True, slots=True) +class _VectorModelRegistration: + record_type: type[Any] + definition: VectorStoreCollectionDefinition + encoder: VectorModelEncoder + decoder: VectorModelDecoder + + +_VECTOR_MODEL_REGISTRY: dict[type[Any], _VectorModelRegistration] = {} + + +def _default_vector_model_encoder(record_type: type[Any]) -> VectorModelEncoder: + def encode(value: Any) -> Mapping[str, Any]: + if not isinstance(value, record_type): + raise TypeError(f"Expected {record_type.__name__}, got {type(value).__name__}.") + converted = msgspec.to_builtins(value, str_keys=True, enc_hook=_msgspec_enc_hook) + if not isinstance(converted, Mapping): + raise TypeError(f"Vector model {record_type.__name__!r} must serialize to a mapping.") + return cast(Mapping[str, Any], converted) + + return encode + + +def _default_vector_model_decoder(record_type: type[Any]) -> VectorModelDecoder: + if issubclass(record_type, BaseModel): + + def decode_pydantic(value: Mapping[str, Any]) -> Any: + validation_value = { + field.validation_alias + if isinstance(field.validation_alias, str) + else field.alias + if isinstance(field.alias, str) + else name: value[name] + for name, field in record_type.model_fields.items() + if name in value + } + return record_type.model_validate(validation_value) + + return decode_pydantic + if is_dataclass(record_type) or issubclass(record_type, msgspec.Struct): + return lambda value: msgspec.convert(value, record_type) + return lambda value: record_type(**value) + + +@experimental(feature_id=ExperimentalFeature.VECTOR_STORES) +def register_vectorstoremodel( + record_type: type[ModelT], + *, + definition: VectorStoreCollectionDefinition, + encoder: Callable[[ModelT], Mapping[str, Any]] | None = None, + decoder: Callable[[Mapping[str, Any]], ModelT] | None = None, +) -> None: + """Register one vector store definition and codec pair for a model type. + + Args: + record_type: The model type to register. + definition: The vector store collection definition for the model. + encoder: Optional callback that converts a model instance to a mapping. + decoder: Optional callback that reconstructs a model instance from a mapping. + This can restore array-like fields such as NumPy arrays without requiring + Agent Framework to depend on NumPy. + + Raises: + ValueError: If the model type is already registered differently. + """ + existing = _VECTOR_MODEL_REGISTRY.get(record_type) + if existing is not None: + if existing.definition is not definition: + raise ValueError(f"Vector model {record_type.__name__!r} is already registered with another definition.") + if encoder is not None and existing.encoder is not encoder: + raise ValueError(f"Vector model {record_type.__name__!r} is already registered with another encoder.") + if decoder is not None and existing.decoder is not decoder: + raise ValueError(f"Vector model {record_type.__name__!r} is already registered with another decoder.") + return + if decoder is None: + required_vector_fields = [ + field.name for field in definition.vector_fields if not _has_default(record_type, field.name) + ] + if required_vector_fields: + raise ValueError( + "Vector fields omitted by include_vectors=False must declare defaults when using the default decoder. " + f"Add defaults or supply a custom decoder for: {', '.join(required_vector_fields)}." + ) + resolved_encoder = ( + cast(VectorModelEncoder, encoder) if encoder is not None else _default_vector_model_encoder(record_type) + ) + resolved_decoder = ( + cast(VectorModelDecoder, decoder) if decoder is not None else _default_vector_model_decoder(record_type) + ) + registration = _VectorModelRegistration( + record_type=record_type, + definition=definition, + encoder=resolved_encoder, + decoder=resolved_decoder, + ) + _VECTOR_MODEL_REGISTRY[record_type] = registration + + +def _has_default(record_type: type[Any], field_name: str) -> bool: + if issubclass(record_type, BaseModel) and field_name in record_type.model_fields: + return not record_type.model_fields[field_name].is_required() + try: + parameter = signature(record_type).parameters.get(field_name) + except (TypeError, ValueError): + parameter = None + if parameter is not None: + return parameter.default is not Parameter.empty + return hasattr(record_type, field_name) + + +def _unwrap_annotation(annotation: Any) -> Any: + if get_origin(annotation) is Annotated: + return get_args(annotation)[0] + return annotation + + +def _without_none(annotation: Any) -> tuple[Any, ...]: + args = get_args(annotation) + if get_origin(annotation) in (UnionType, Union): + return tuple(arg for arg in args if arg is not type(None)) + return (annotation,) + + +def _infer_type_name(annotation: Any, *, vector: bool) -> str | None: + candidates = _without_none(_unwrap_annotation(annotation)) + if vector: + for candidate in candidates: + origin = get_origin(candidate) + args = get_args(candidate) + if origin is not None and args: + candidate = next((arg for arg in args if arg is not Ellipsis), candidate) + return getattr(candidate, "__name__", str(candidate)) + candidate = candidates[0] if candidates else annotation + origin = get_origin(candidate) + return getattr(origin or candidate, "__name__", None) + + +def _parse_model_definition( + record_type: type[Any], + *, + collection_name: str | None, +) -> VectorStoreCollectionDefinition: + try: + annotations = get_type_hints(record_type, include_extras=True) + except (NameError, TypeError) as exc: + raise ValueError(f"Unable to resolve annotations for {record_type.__name__}: {exc}") from exc + uses_init_annotations = not any( + any(isinstance(metadata, VectorStoreField) for metadata in get_args(annotation)[1:]) + for annotation in annotations.values() + if get_origin(annotation) is Annotated + ) + init_parameters: Mapping[str, Parameter] = {} + if uses_init_annotations: + try: + annotations = { + name: annotation + for name, annotation in get_type_hints(record_type.__init__, include_extras=True).items() + if name not in {"self", "return"} + } + init_parameters = signature(record_type.__init__).parameters + except (NameError, TypeError, ValueError) as exc: + raise ValueError(f"Unable to resolve constructor annotations for {record_type.__name__}: {exc}") from exc + if not annotations: + raise ValueError("A vector store model must declare at least one annotated field or constructor parameter.") + + fields: list[VectorStoreField] = [] + for name, annotation in annotations.items(): + metadata = get_args(annotation)[1:] if get_origin(annotation) is Annotated else () + field = next((item for item in metadata if isinstance(item, VectorStoreField)), None) + if field is None: + has_default = ( + init_parameters[name].default is not Parameter.empty + if uses_init_annotations + else _has_default(record_type, name) + ) + if not has_default: + raise ValueError(f"Field '{name}' must use VectorStoreField metadata or declare a default value.") + continue + + parsed_field = replace( + field, + name=name, + type_=field.type_ or _infer_type_name(annotation, vector=field.field_type == "vector"), + ) + fields.append(parsed_field) + return VectorStoreCollectionDefinition(fields, collection_name=collection_name) + + +class _VectorStoreModelDecorator(Protocol): + def __call__(self, record_type: type[DecoratedModelT]) -> type[DecoratedModelT]: + """Decorate a model while preserving its concrete type.""" + ... + + +@overload +def vectorstoremodel(cls: type[ModelT]) -> type[ModelT]: + """Decorate a vector store model without arguments. + + Args: + cls: The class to decorate. + + Returns: + The original class with vector store model metadata attached. + + Raises: + ValueError: If the model definition is invalid. + """ + ... + + +@overload +def vectorstoremodel( + cls: None = None, + *, + collection_name: str | None = None, + encoder: Callable[[Any], Mapping[str, Any]] | None = None, + decoder: Callable[[Mapping[str, Any]], Any] | None = None, +) -> _VectorStoreModelDecorator: + """Create a vector store model decorator with a collection name. + + Args: + cls: The empty decorator target used when calling the decorator with arguments. + collection_name: The collection name associated with the model. + encoder: Optional callback that converts a model instance to a mapping. + decoder: Optional callback that reconstructs a model instance from a mapping. + This can restore array-like fields such as NumPy arrays without requiring + Agent Framework to depend on NumPy. + + Returns: + A decorator that attaches vector store model metadata. + + Raises: + ValueError: When the returned decorator receives an invalid model definition. + """ + ... + + +@experimental(feature_id=ExperimentalFeature.VECTOR_STORES) +def vectorstoremodel( + cls: type[Any] | None = None, + *, + collection_name: str | None = None, + encoder: Callable[[Any], Mapping[str, Any]] | None = None, + decoder: Callable[[Mapping[str, Any]], Any] | None = None, +) -> type[Any] | _VectorStoreModelDecorator: + """Mark a class as a vector store model. + + Class fields or constructor parameters use ``Annotated`` metadata to describe their + vector store role. Dataclasses, Pydantic models, and plain classes are supported. + Dictionaries use an explicit :class:`VectorStoreCollectionDefinition` instead. + + Args: + cls: The class to decorate. + collection_name: The collection name associated with the model. + encoder: Optional callback that converts a model instance to a mapping. + decoder: Optional callback that reconstructs a model instance from a mapping. + + Returns: + The original class with vector store model metadata attached. + + Raises: + ValueError: If the model definition is invalid. + """ + + def wrap(record_type: type[DecoratedModelT]) -> type[DecoratedModelT]: + definition = _parse_model_definition(record_type, collection_name=collection_name) + register_vectorstoremodel( + record_type, + definition=definition, + encoder=encoder, + decoder=decoder, + ) + decorated_type = cast(Any, record_type) + decorated_type.__vectorstoremodel__ = True + decorated_type.__vectorstoremodel_definition__ = definition + return record_type + + return wrap if cls is None else wrap(cls) + + +def _validate_paging(*, top: int, skip: int) -> None: + if not isinstance(top, int) or isinstance(top, bool): + raise TypeError("top must be an integer.") + if not isinstance(skip, int) or isinstance(skip, bool): + raise TypeError("skip must be an integer.") + if top <= 0: + raise ValueError("top must be greater than zero.") + if skip < 0: + raise ValueError("skip must not be negative.") + + +@experimental(feature_id=ExperimentalFeature.VECTOR_STORES) +class SearchResponse(TypedDict, Generic[ModelT]): + """One vector search result.""" + + record: ModelT + score: float | None + + +@experimental(feature_id=ExperimentalFeature.VECTOR_STORES) +class SearchResults(Generic[ResultT]): + """A lazily consumed set of vector search results. + + Connector-native counts may be placed in ``metadata`` together with enough + provider-specific context to explain their scope. + """ + + def __init__( + self, + results: AsyncIterable[ResultT] | Sequence[ResultT], + *, + metadata: Mapping[str, Any] | None = None, + ) -> None: + """Initialize search results.""" + self.results = _as_async_iterable(results) + self.metadata = metadata + + def __aiter__(self) -> AsyncIterator[ResultT]: + """Iterate over results regardless of whether their source was synchronous or asynchronous.""" + return self.results.__aiter__() + + +class _VectorStoreRecordHandler(Generic[KeyT, ModelT]): + """Serialize and deserialize application records for a vector store.""" + + supported_key_types: ClassVar[set[str] | None] = None + supported_vector_types: ClassVar[set[str] | None] = None + + def __init__( + self, + record_type: type[ModelT], + *, + definition: VectorStoreCollectionDefinition | None = None, + embedding_generator: EmbeddingClient | None = None, + ) -> None: + """Initialize a vector store record handler. + + Args: + record_type: The application record type. + definition: The collection definition. Decorated models supply this automatically. + embedding_generator: The default client used for local vector generation. + + Raises: + ValueError: If no model registration or explicit dictionary definition is available. + """ + registration = _VECTOR_MODEL_REGISTRY.get(record_type) + if record_type is dict: + if definition is None: + raise ValueError("Dictionary record types require an explicit VectorStoreCollectionDefinition.") + resolved_definition = definition + else: + if registration is None: + raise ValueError( + f"Record type {record_type.__name__!r} must be registered with " + "@vectorstoremodel or register_vectorstoremodel()." + ) + if definition is not None and definition is not registration.definition: + raise ValueError(f"Record type {record_type.__name__!r} is registered with another definition.") + resolved_definition = registration.definition + self.record_type = record_type + self.definition = resolved_definition + self._model_registration = registration + self.embedding_generator = embedding_generator + self._validate_data_model() + + def _validate_data_model(self) -> None: + key_type = self.definition.key_field.type_ + if self.supported_key_types and key_type and key_type not in self.supported_key_types: + raise ValueError(f"Key field type must be one of {self.supported_key_types}; got '{key_type}'.") + if not self.supported_vector_types: + return + for field in self.definition.vector_fields: + if field.type_ and field.type_ not in self.supported_vector_types: + raise ValueError( + f"Vector field '{field.name}' type must be one of {self.supported_vector_types}; " + f"got '{field.type_}'." + ) + + def _serialize_dicts_to_store_models( + self, + records: Sequence[dict[str, Any]], + *, + context: Mapping[str, Any] | None = None, + ) -> Sequence[Any]: + """Convert dictionaries to store-specific records.""" + return records + + def _deserialize_store_models_to_dicts( + self, + records: Sequence[Any], + *, + context: Mapping[str, Any] | None = None, + ) -> Sequence[dict[str, Any]]: + """Convert store-specific records to dictionaries.""" + dict_records: list[dict[str, Any]] = [] + for record in records: + if not isinstance(record, Mapping): + raise TypeError("Store records must be mappings unless the collection overrides deserialization.") + dict_records.append(dict(cast(Mapping[str, Any], record))) + return dict_records + + async def serialize( + self, + records: ModelT | Sequence[ModelT], + *, + generate_vectors: bool = True, + context: Mapping[str, Any] | None = None, + ) -> Any: + """Serialize one or more application records for the backing store. + + Args: + records: One application record or a sequence of records. + generate_vectors: Whether to generate vector values, overwriting any supplied values. When ``False``, + supplied values are preserved. + context: Connector-specific serialization context. + + Raises: + TypeError: If a record cannot be converted to a mapping. + ValueError: If required record data is missing, has an invalid shape, or a vector field has no generator. + IntegrationInvalidResponseException: If embedding generation returns an unexpected result count. + """ + mark_feature_used(FeatureIndex.CORE_VECTOR_STORES) + is_batch = _is_non_string_sequence(records) + input_records = list(cast(Sequence[ModelT], records)) if is_batch else [cast(ModelT, records)] + dict_records = [self._serialize_record_to_dict(record) for record in input_records] + + if generate_vectors: + await self._add_vectors_to_records(dict_records) + store_models = list(self._serialize_dicts_to_store_models(dict_records, context=context)) + + if len(store_models) != len(dict_records): + raise IntegrationInvalidResponseException( + f"Expected {len(dict_records)} serialized records, but the connector returned {len(store_models)}." + ) + if is_batch: + return store_models + if len(store_models) != 1: + raise ValueError(f"Expected one serialized record, but the serializer returned {len(store_models)}.") + return store_models[0] + + def _serialize_record_to_dict(self, record: ModelT) -> dict[str, Any]: + if self.record_type is dict: + source = self._to_builtin_mapping(record) + else: + if self._model_registration is None: + raise RuntimeError(f"Vector model {self.record_type.__name__!r} is not registered.") + source = self._to_builtin_mapping(self._model_registration.encoder(record)) + return self._serialize_mapping_to_store(source) + + @staticmethod + def _to_builtin_mapping(record: Any) -> Mapping[str, Any]: + converted = msgspec.to_builtins(record, str_keys=True, enc_hook=_msgspec_enc_hook) + if not isinstance(converted, Mapping): + raise TypeError("Vector records must serialize to mappings.") + return cast(Mapping[str, Any], converted) + + def _serialize_mapping_to_store(self, source: Mapping[str, Any]) -> dict[str, Any]: + serialized: dict[str, Any] = {} + for field in self.definition.fields: + if field.name in source: + value = source[field.name] + elif field.storage_name is not None and field.storage_name in source: + value = source[field.storage_name] + else: + raise ValueError(f"Record is missing vector store field '{field.name}'.") + serialized[field.storage_name or field.name] = value + return serialized + + async def _add_vectors_to_records(self, records: Sequence[dict[str, Any]]) -> None: + field_generators: list[tuple[VectorStoreField, EmbeddingClient]] = [] + for field in self.definition.vector_fields: + embedding_generator = field.embedding_generator or self.embedding_generator + if embedding_generator is None: + raise ValueError( + f"Vector field '{field.name}' has no embedding generator. " + "Set generate_vectors=False to preserve supplied vector values." + ) + field_generators.append((field, embedding_generator)) + + for field, embedding_generator in field_generators: + storage_name = field.storage_name or field.name + values = [record.get(storage_name) for record in records] + if any(value is None for value in values): + raise ValueError( + f"Vector field '{field.name}' cannot be embedded because at least one value is missing." + ) + options: EmbeddingGenerationOptions = {} + if field.dimensions is not None: + options["dimensions"] = field.dimensions + embeddings = await embedding_generator.get_embeddings(values, options=options) + if len(embeddings) != len(records): + raise IntegrationInvalidResponseException( + f"Embedding client returned {len(embeddings)} vectors for {len(records)} records." + ) + for record, embedding in zip(records, embeddings, strict=True): + record[storage_name] = _normalize_vector(embedding.vector) + + def deserialize( + self, + records: Any | Sequence[Any], + *, + include_vectors: bool = True, + context: Mapping[str, Any] | None = None, + ) -> ModelT | Sequence[ModelT] | None: + """Deserialize one or more backing store records. + + Raises: + TypeError: If a store record has an unsupported type. + ValueError: If records cannot be reconstructed into the requested model shape. + """ + mark_feature_used(FeatureIndex.CORE_VECTOR_STORES) + if records is None: + return None + is_batch = _is_non_string_sequence(records) + input_records = list(records) if is_batch else [records] + dict_records = self._deserialize_store_models_to_dicts(input_records, context=context) + if not dict_records: + return [] if is_batch else None + deserialized = [ + self._deserialize_dict_to_record(record, include_vectors=include_vectors) for record in dict_records + ] + return deserialized if is_batch else deserialized[0] + + def _deserialize_dict_to_record( + self, + record: Mapping[str, Any], + *, + include_vectors: bool, + ) -> ModelT: + logical_record = self._deserialize_dict_to_mapping(record, include_vectors=include_vectors) + if self.record_type is dict: + return cast(ModelT, logical_record) + if self._model_registration is None: + raise RuntimeError(f"Vector model {self.record_type.__name__!r} is not registered.") + return cast(ModelT, self._model_registration.decoder(logical_record)) + + def _deserialize_dict_to_mapping( + self, + record: Mapping[str, Any], + *, + include_vectors: bool, + ) -> dict[str, Any]: + logical_record: dict[str, Any] = {} + for field in self.definition.fields: + if not include_vectors and field.field_type == "vector": + continue + storage_name = field.storage_name or field.name + if storage_name not in record: + raise IntegrationInvalidResponseException( + f"Vector store response is missing required field '{storage_name}'." + ) + logical_record[field.name] = record[storage_name] + return logical_record + + +@experimental(feature_id=ExperimentalFeature.VECTOR_STORES) +class BaseVectorCollection(_VectorStoreRecordHandler[KeyT, ModelT], ABC): + """Base class for vector store collection CRUD operations.""" + + def __init__( + self, + record_type: type[ModelT], + *, + definition: VectorStoreCollectionDefinition | None = None, + collection_name: str | None = None, + embedding_generator: EmbeddingClient | None = None, + managed_client: bool = True, + ) -> None: + """Initialize a vector store collection.""" + super().__init__( + record_type, + definition=definition, + embedding_generator=embedding_generator, + ) + self.collection_name = collection_name or self.definition.collection_name or "" + if not self.collection_name: + raise ValueError("A collection name is required when the model definition does not provide one.") + self.managed_client = managed_client + + async def __aenter__(self) -> Self: + """Enter the collection context manager.""" + return self + + async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: + """Exit the collection context manager.""" + + @abstractmethod + async def ensure_collection_exists( + self, + *, + operation_options: Mapping[str, Any] | None = None, + ) -> None: + """Create the collection when it does not exist.""" + ... + + @abstractmethod + async def collection_exists( + self, + *, + operation_options: Mapping[str, Any] | None = None, + ) -> bool: + """Check whether the collection exists.""" + ... + + @abstractmethod + async def ensure_collection_deleted( + self, + *, + operation_options: Mapping[str, Any] | None = None, + ) -> None: + """Delete the collection when it exists.""" + ... + + @abstractmethod + async def _inner_upsert( + self, + records: Sequence[Any], + *, + operation_options: Mapping[str, Any] | None = None, + ) -> Sequence[KeyT]: + """Upsert serialized records and return their keys.""" + ... + + @abstractmethod + async def _inner_get( + self, + *, + keys: Sequence[KeyT] | None = None, + top: int = 10, + skip: int = 0, + order_by: Mapping[str, bool] | None = None, + include_vectors: bool = False, + operation_options: Mapping[str, Any] | None = None, + ) -> Sequence[Any] | None: + """Retrieve store-specific records.""" + ... + + @abstractmethod + async def _inner_delete( + self, + keys: Sequence[KeyT], + *, + operation_options: Mapping[str, Any] | None = None, + ) -> None: + """Delete records by key.""" + ... + + async def upsert( + self, + records: Sequence[ModelT], + *, + generate_vectors: bool = True, + operation_options: Mapping[str, Any] | None = None, + ) -> Sequence[KeyT]: + """Upsert a batch of records. + + Args: + records: A sequence of models. + generate_vectors: Whether to generate vector values, overwriting any supplied values. When ``False``, + supplied values are preserved. + operation_options: Store-specific operation options. + + Returns: + The keys of all upserted records. + + Raises: + TypeError: If record serialization encounters an unsupported type. + ValueError: If record data or returned keys have an invalid shape, or a vector field has no generator. + IntegrationException: If the backing store operation fails. + IntegrationInvalidResponseException: If the backing store returns an unexpected key count. + """ + mark_feature_used(FeatureIndex.CORE_VECTOR_STORES) + if not _is_non_string_sequence(records): + raise TypeError("records must be a sequence.") + try: + serialized = await self.serialize(records, generate_vectors=generate_vectors) + store_records = list(serialized) if _is_non_string_sequence(serialized) else [serialized] + keys = list(await self._inner_upsert(store_records, operation_options=operation_options)) + except (TypeError, ValueError): + raise + except IntegrationException: + raise + except Exception as exc: + raise IntegrationException( + f"Error upserting records into collection '{self.collection_name}': {exc}" + ) from exc + if len(keys) != len(store_records): + raise IntegrationInvalidResponseException( + f"Expected {len(store_records)} upserted keys, but the store returned {len(keys)}." + ) + return keys + + async def get( + self, + keys: Sequence[KeyT] | None = None, + *, + top: int = 10, + skip: int = 0, + order_by: Mapping[str, bool] | None = None, + include_vectors: bool = False, + operation_options: Mapping[str, Any] | None = None, + ) -> Sequence[ModelT]: + """Get records by keys or list a page of records. + + Args: + keys: A sequence of keys, or ``None`` to list a page of records. + top: The maximum number of records returned when listing. + skip: The number of records skipped when listing. + order_by: Field names mapped to ascending (``True``) or descending (``False``) order. + include_vectors: Whether returned records include vector fields. + operation_options: Store-specific operation options. + + Returns: + A sequence of models. Keys that do not exist are omitted. + + Raises: + ValueError: If paging arguments are invalid. + TypeError: If keys or a returned record has an unsupported type. + IntegrationException: If retrieval fails. + """ + mark_feature_used(FeatureIndex.CORE_VECTOR_STORES) + _validate_paging(top=top, skip=skip) + if keys is not None and not _is_non_string_sequence(keys): + raise TypeError("keys must be a sequence.") + try: + records = await self._inner_get( + keys=keys, + top=top, + skip=skip, + order_by=order_by, + include_vectors=include_vectors, + operation_options=operation_options, + ) + except IntegrationException: + raise + except Exception as exc: + raise IntegrationException( + f"Error getting records from collection '{self.collection_name}': {exc}" + ) from exc + if not records: + return [] + deserialized = self.deserialize(records, include_vectors=include_vectors) + return [] if deserialized is None else cast(Sequence[ModelT], deserialized) + + async def delete( + self, + keys: Sequence[KeyT], + *, + operation_options: Mapping[str, Any] | None = None, + ) -> None: + """Delete a batch of records by key. + + Args: + keys: The keys to delete. + operation_options: Store-specific operation options. + + Raises: + TypeError: If keys is not a sequence. + IntegrationException: If the backing store operation fails. + """ + mark_feature_used(FeatureIndex.CORE_VECTOR_STORES) + if not _is_non_string_sequence(keys): + raise TypeError("keys must be a sequence.") + try: + await self._inner_delete(keys, operation_options=operation_options) + except IntegrationException: + raise + except Exception as exc: + raise IntegrationException( + f"Error deleting records from collection '{self.collection_name}': {exc}" + ) from exc + + +@experimental(feature_id=ExperimentalFeature.VECTOR_STORES) +class BaseVectorStore(ABC): + """Base class for vector stores that create collection clients.""" + + def __init__( + self, + *, + embedding_generator: EmbeddingClient | None = None, + managed_client: bool = True, + ) -> None: + """Initialize a vector store.""" + self.embedding_generator = embedding_generator + self.managed_client = managed_client + + async def __aenter__(self) -> Self: + """Enter the vector store context manager.""" + return self + + async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: + """Exit the vector store context manager.""" + + @abstractmethod + def get_collection( + self, + record_type: type[ModelT], + *, + definition: VectorStoreCollectionDefinition | None = None, + collection_name: str | None = None, + embedding_generator: EmbeddingClient | None = None, + ) -> BaseVectorCollection[Any, ModelT]: + """Create a collection client tied to this store.""" + ... + + @abstractmethod + async def list_collection_names( + self, + *, + operation_options: Mapping[str, Any] | None = None, + ) -> Sequence[str]: + """List collection names.""" + ... + + async def collection_exists( + self, + collection_name: str, + *, + operation_options: Mapping[str, Any] | None = None, + ) -> bool: + """Check whether a collection exists.""" + mark_feature_used(FeatureIndex.CORE_VECTOR_STORES) + return collection_name in await self.list_collection_names(operation_options=operation_options) + + async def ensure_collection_deleted( + self, + collection_name: str, + *, + operation_options: Mapping[str, Any] | None = None, + ) -> None: + """Delete a collection when it exists.""" + if not await self.collection_exists(collection_name, operation_options=operation_options): + return + await self._inner_ensure_collection_deleted( + collection_name, + operation_options=operation_options, + ) + + @abstractmethod + async def _inner_ensure_collection_deleted( + self, + collection_name: str, + *, + operation_options: Mapping[str, Any] | None = None, + ) -> None: + """Delete a collection by name.""" + ... + + +class _LambdaVisitor(NodeVisitor, Generic[FilterT]): + def __init__(self, lambda_parser: Callable[[expr], FilterT]) -> None: + self.lambda_parser = lambda_parser + self.output_filters: list[FilterT] = [] + + def visit_Lambda(self, node: Lambda) -> None: + self.output_filters.append(self.lambda_parser(node.body)) + + +@experimental(feature_id=ExperimentalFeature.VECTOR_STORES) +class BaseVectorSearch(_VectorStoreRecordHandler[KeyT, ModelT], ABC): + """Base class for vector and keyword-hybrid search.""" + + supported_search_types: ClassVar[set[SearchType]] = {"vector"} + + @abstractmethod + async def _inner_search( + self, + *, + search_type: SearchType, + filter: Any | list[Any] | None = None, + values: Any | None = None, + vector: Vector | None = None, + top: int = 3, + skip: int = 0, + include_vectors: bool = False, + vector_property_name: str | None = None, + additional_property_name: str | None = None, + score_threshold: float | None = None, + operation_options: Mapping[str, Any] | None = None, + ) -> SearchResults[Any]: + """Execute a search and return raw connector results.""" + ... + + @abstractmethod + def _get_record_from_result(self, result: Any) -> Any: + """Extract a store record from one raw search result.""" + ... + + @abstractmethod + def _get_score_from_result(self, result: Any) -> float | None: + """Extract a score from one raw search result.""" + ... + + @abstractmethod + def _lambda_parser(self, node: AST) -> Any: + """Translate one lambda expression body into a store filter.""" + ... + + @overload + async def search( + self, + values: Any, + *, + search_type: SearchType = "vector", + vector: Vector | None = None, + filter: RecordFilters | None = None, + top: int = 3, + skip: int = 0, + include_vectors: bool = False, + vector_property_name: str | None = None, + additional_property_name: str | None = None, + score_threshold: float | None = None, + operation_options: Mapping[str, Any] | None = None, + ) -> SearchResults[SearchResponse[ModelT]]: + """Search from a value, optionally with a precomputed vector. + + Args: + values: The value to search for or vectorize. + search_type: Whether to perform vector or keyword-hybrid search. + vector: An optional precomputed query vector. + filter: One or more lambda filters. + top: The maximum number of results. + skip: The number of results to skip. + include_vectors: Whether returned records include vector fields. + vector_property_name: The vector field used for search. + additional_property_name: The data field used for keyword-hybrid search. + score_threshold: The minimum similarity or maximum distance accepted. + Results without scores remain included. + operation_options: Store-specific operation options. + + Returns: + Lazily consumed search results. + + Raises: + ValueError: If paging or search arguments are invalid. + NotImplementedError: If the search type is unsupported. + IntegrationException: If vector generation or search fails. + """ + ... + + @overload + async def search( + self, + *, + search_type: Literal["vector"] = "vector", + vector: Vector, + filter: RecordFilters | None = None, + top: int = 3, + skip: int = 0, + include_vectors: bool = False, + vector_property_name: str | None = None, + additional_property_name: str | None = None, + score_threshold: float | None = None, + operation_options: Mapping[str, Any] | None = None, + ) -> SearchResults[SearchResponse[ModelT]]: + """Search from a required precomputed vector. + + Args: + search_type: The vector search type. + vector: The precomputed query vector. + filter: One or more lambda filters. + top: The maximum number of results. + skip: The number of results to skip. + include_vectors: Whether returned records include vector fields. + vector_property_name: The vector field used for search. + additional_property_name: The data field used for keyword-hybrid search. + score_threshold: The minimum similarity or maximum distance accepted. + Results without scores remain included. + operation_options: Store-specific operation options. + + Returns: + Lazily consumed search results. + + Raises: + ValueError: If paging or search arguments are invalid. + NotImplementedError: If vector search is unsupported. + IntegrationException: If search execution fails. + """ + ... + + async def search( + self, + values: Any | None = None, + *, + search_type: SearchType = "vector", + vector: Vector | None = None, + filter: RecordFilters | None = None, + top: int = 3, + skip: int = 0, + include_vectors: bool = False, + vector_property_name: str | None = None, + additional_property_name: str | None = None, + score_threshold: float | None = None, + operation_options: Mapping[str, Any] | None = None, + ) -> SearchResults[SearchResponse[ModelT]]: + """Search the vector store. + + Args: + values: The value to search for or vectorize. + search_type: Whether to perform vector or keyword-hybrid search. + vector: A precomputed query vector. + filter: One or more lambda filters. + top: The maximum number of results. + skip: The number of results to skip. + include_vectors: Whether returned records include vector fields. + vector_property_name: The vector field used for search. + additional_property_name: The data field used for keyword-hybrid search. + score_threshold: The minimum similarity or maximum distance accepted. + Results without scores remain included. + operation_options: Store-specific operation options. + + Returns: + Lazily consumed search results. + + Raises: + ValueError: If paging or search arguments are invalid. + NotImplementedError: If the search type is unsupported. + IntegrationException: If the backing store search fails. + """ + mark_feature_used(FeatureIndex.CORE_VECTOR_STORES) + if search_type not in ("vector", "keyword_hybrid"): + raise ValueError(f"Unknown search type '{search_type}'.") + if search_type not in self.supported_search_types: + raise NotImplementedError(f"Search type '{search_type}' is not supported by {type(self).__name__}.") + if values is None and vector is None: + raise ValueError("Search requires values or a precomputed vector.") + if search_type == "keyword_hybrid" and values is None: + raise ValueError("Keyword-hybrid search requires values.") + + _validate_paging(top=top, skip=skip) + try: + self._validate_score_threshold( + score_threshold=score_threshold, + vector_property_name=vector_property_name, + ) + resolved_vector = vector + if resolved_vector is None and values is not None: + resolved_vector = await self._generate_vector_from_values( + values, + vector_property_name=vector_property_name, + ) + translated_filter = self._build_filter(filter) + raw_results = await self._inner_search( + search_type=search_type, + filter=translated_filter, + values=values, + vector=resolved_vector, + top=top, + skip=skip, + include_vectors=include_vectors, + vector_property_name=vector_property_name, + additional_property_name=additional_property_name, + score_threshold=score_threshold, + operation_options=operation_options, + ) + return SearchResults( + self._get_search_results_from_results( + raw_results.results, + include_vectors=include_vectors, + vector_property_name=vector_property_name, + score_threshold=score_threshold, + ), + metadata=raw_results.metadata, + ) + except (TypeError, ValueError): + raise + except IntegrationException: + raise + except Exception as exc: + raise IntegrationException(f"Vector search failed: {exc}") from exc + + def _validate_score_threshold( + self, + *, + score_threshold: float | None, + vector_property_name: str | None, + ) -> None: + if score_threshold is None: + return + vector_field = self.definition.try_get_vector_field(vector_property_name) + if vector_field is None: + raise ValueError("A score threshold requires a vector field.") + if vector_field.distance_function == "DEFAULT": + raise ValueError("A score threshold requires an explicit distance function on the vector field.") + + async def _generate_vector_from_values( + self, + values: Any, + *, + vector_property_name: str | None, + ) -> Vector | None: + vector_field = self.definition.try_get_vector_field(vector_property_name) + if vector_field is None: + if vector_property_name is not None: + raise ValueError(f"Vector field '{vector_property_name}' was not found in the collection definition.") + return None + embedding_generator = vector_field.embedding_generator or self.embedding_generator + if embedding_generator is None: + return None + embedding_options: EmbeddingGenerationOptions = {} + if vector_field.dimensions is not None: + embedding_options["dimensions"] = vector_field.dimensions + embeddings = await embedding_generator.get_embeddings([values], options=embedding_options) + if len(embeddings) != 1: + raise IntegrationInvalidResponseException( + f"Embedding client returned {len(embeddings)} vectors for one search value." + ) + generated_vector = embeddings[0].vector + return _normalize_vector(generated_vector) + + def _build_filter(self, search_filter: RecordFilters | None) -> Any | list[Any] | None: + """Translate lambda filters with the connector's AST parser.""" + if not search_filter: + return None + filters: list[RecordFilter] + if _is_non_string_sequence(search_filter) and not callable(search_filter): + filters = cast(list[RecordFilter], list(search_filter)) + else: + filters = [cast(RecordFilter, search_filter)] + visitor = _LambdaVisitor(self._lambda_parser) + try: + for filter_item in filters: + source = ( + filter_item + if isinstance(filter_item, str) + else getsource(cast(Callable[..., Any], filter_item)).strip() + ) + visitor.visit(parse(source)) + except (OSError, SyntaxError, TypeError) as exc: + raise ValueError(f"Unable to parse vector search filter: {exc}") from exc + if not visitor.output_filters: + raise ValueError("No lambda expression was found in the vector search filter.") + return visitor.output_filters[0] if len(visitor.output_filters) == 1 else visitor.output_filters + + def _get_search_results_from_results( + self, + results: AsyncIterable[Any] | Sequence[Any], + *, + include_vectors: bool, + vector_property_name: str | None, + score_threshold: float | None, + ) -> AsyncIterable[SearchResponse[ModelT]]: + """Convert raw connector results into deserialized search responses.""" + + async def generate() -> AsyncIterator[SearchResponse[ModelT]]: + try: + async for result in _as_async_iterable(results): + try: + record = self.deserialize( + self._get_record_from_result(result), + include_vectors=include_vectors, + ) + if record is None or _is_non_string_sequence(record): + if record is None: + continue + raise IntegrationInvalidResponseException( + "A search result must deserialize to exactly one record." + ) + score = self._get_score_from_result(result) + if not self._meets_score_threshold( + score, + score_threshold=score_threshold, + vector_property_name=vector_property_name, + ): + continue + yield SearchResponse(record=cast(ModelT, record), score=score) + except IntegrationException: + raise + except Exception as exc: + raise IntegrationInvalidResponseException( + f"Vector search result conversion failed: {exc}" + ) from exc + except IntegrationException: + raise + except Exception as exc: + raise IntegrationException(f"Vector search iteration failed: {exc}") from exc + + return generate() + + def _meets_score_threshold( + self, + score: float | None, + *, + score_threshold: float | None, + vector_property_name: str | None, + ) -> bool: + """Apply a threshold when a result includes a comparable score. + + Results without scores remain included because the threshold cannot be + evaluated for them. + """ + if score_threshold is None or score is None: + return True + vector_field = self.definition.try_get_vector_field(vector_property_name) + if vector_field is None or vector_field.distance_function is None: + return True + comparison = DISTANCE_FUNCTION_DIRECTION_HELPER.get(vector_field.distance_function) + return comparison(score, score_threshold) if comparison is not None else True + + +@runtime_checkable +@experimental(feature_id=ExperimentalFeature.VECTOR_STORES) +class SupportsVectorUpsert(Protocol[KeyT, ModelT]): + """Protocol for vector collection CRUD operations.""" + + collection_name: str + record_type: type[ModelT] + definition: VectorStoreCollectionDefinition + + async def upsert( + self, + records: Sequence[ModelT], + *, + generate_vectors: bool = True, + operation_options: Mapping[str, Any] | None = None, + ) -> Sequence[KeyT]: + """Upsert a batch of records, generating embeddings by default.""" + ... + + async def get( + self, + keys: Sequence[KeyT] | None = None, + *, + top: int = 10, + skip: int = 0, + order_by: Mapping[str, bool] | None = None, + include_vectors: bool = False, + operation_options: Mapping[str, Any] | None = None, + ) -> Sequence[ModelT]: + """Get records by keys or list a page of records, excluding vectors by default.""" + ... + + async def delete( + self, + keys: Sequence[KeyT], + *, + operation_options: Mapping[str, Any] | None = None, + ) -> None: + """Delete a batch of records by key.""" + ... + + +@runtime_checkable +@experimental(feature_id=ExperimentalFeature.VECTOR_STORES) +class SupportsVectorSearch(Protocol[ModelT]): + """Protocol for vector and keyword-hybrid search.""" + + @overload + async def search( + self, + values: Any, + *, + search_type: SearchType = "vector", + vector: Vector | None = None, + filter: RecordFilters | None = None, + top: int = 3, + skip: int = 0, + include_vectors: bool = False, + vector_property_name: str | None = None, + additional_property_name: str | None = None, + score_threshold: float | None = None, + operation_options: Mapping[str, Any] | None = None, + ) -> SearchResults[SearchResponse[ModelT]]: + """Search from a value, optionally with a precomputed vector. + + Args: + values: The value to search for or vectorize. + search_type: Whether to perform vector or keyword-hybrid search. + vector: An optional precomputed query vector. + filter: One or more lambda filters. + top: The maximum number of results. + skip: The number of results to skip. + include_vectors: Whether returned records include vector fields. + vector_property_name: The vector field used for search. + additional_property_name: The data field used for keyword-hybrid search. + score_threshold: The minimum similarity or maximum distance accepted. + Results without scores remain included. + operation_options: Store-specific operation options. + + Returns: + Lazily consumed search results. + + Raises: + ValueError: If paging or search arguments are invalid. + NotImplementedError: If the search type is unsupported. + IntegrationException: If vector generation or search fails. + """ + ... + + @overload + async def search( + self, + *, + search_type: Literal["vector"] = "vector", + vector: Vector, + filter: RecordFilters | None = None, + top: int = 3, + skip: int = 0, + include_vectors: bool = False, + vector_property_name: str | None = None, + additional_property_name: str | None = None, + score_threshold: float | None = None, + operation_options: Mapping[str, Any] | None = None, + ) -> SearchResults[SearchResponse[ModelT]]: + """Search from a required precomputed vector. + + Args: + search_type: The vector search type. + vector: The precomputed query vector. + filter: One or more lambda filters. + top: The maximum number of results. + skip: The number of results to skip. + include_vectors: Whether returned records include vector fields. + vector_property_name: The vector field used for search. + additional_property_name: The data field used for keyword-hybrid search. + score_threshold: The minimum similarity or maximum distance accepted. + Results without scores remain included. + operation_options: Store-specific operation options. + + Returns: + Lazily consumed search results. + + Raises: + ValueError: If paging or search arguments are invalid. + NotImplementedError: If vector search is unsupported. + IntegrationException: If search execution fails. + """ + ... + + +@experimental(feature_id=ExperimentalFeature.VECTOR_STORES) +def create_vector_search_tool( + search: SupportsVectorSearch[ModelT], + *, + name: str = _DEFAULT_SEARCH_TOOL_NAME, + description: str = _DEFAULT_SEARCH_TOOL_DESCRIPTION, + approval_mode: Literal["always_require", "never_require"] = "never_require", + search_type: SearchType = "vector", + parameters: type[BaseModel] | Mapping[str, Any] | None = None, + top: int = 5, + skip: int = 0, + filter: RecordFilters | None = None, + filter_mapper: Callable[[RecordFilters | None, Mapping[str, Any]], RecordFilters | None] | None = None, + result_mapper: Callable[[SearchResponse[ModelT]], str | Content | Sequence[Content]] | None = None, +) -> FunctionTool: + """Create an agent-usable tool backed by vector search. + + Args: + search: The vector search capability invoked by the tool. + name: The tool name. + description: The tool description shown to the model. + approval_mode: Whether the tool requires approval before invocation. + search_type: Whether the tool performs vector or keyword-hybrid search. + parameters: A Pydantic model or JSON schema declaring the tool parameters. + It must declare ``query`` as a required string. A custom schema can + expose ``top`` and ``skip`` as integers with finite ``maximum`` values; + additional fields are passed to ``filter_mapper``. + top: The default result limit and the maximum when ``parameters`` does not expose ``top``. + skip: The default offset and the maximum when ``parameters`` does not expose ``skip``. + filter: A fixed filter applied to each tool invocation. + filter_mapper: Maps additional declared tool arguments to search filters. + The default creates equality filters for each additional argument. + result_mapper: Maps each search response to text or one or more multimodal content items. + + Returns: + A function tool with only a ``query`` parameter by default. Custom parameters can expose + ``top``, ``skip``, and fields mapped into filters by ``filter_mapper``. + + Raises: + ValueError: If parameters or paging limits are invalid. + NotImplementedError: If the search type is unsupported. + """ + _validate_paging(top=top, skip=skip) + map_filter = filter_mapper or _default_search_filter_mapper + map_result = result_mapper or _default_search_result_mapper + input_model = parameters if parameters is not None else _default_search_tool_parameters() + max_top, max_skip = _validate_search_tool_parameters( + input_model, + default_top=top, + default_skip=skip, + ) + + async def search_tool(**arguments: Any) -> list[Content]: + query = arguments.pop("query") + if not isinstance(query, str): + raise TypeError("The search tool 'query' argument must be a string.") + invocation_top = arguments.pop("top", top) + invocation_skip = arguments.pop("skip", skip) + _validate_paging(top=invocation_top, skip=invocation_skip) + if invocation_top > max_top: + raise ValueError(f"top must not exceed the configured maximum of {max_top}.") + if invocation_skip > max_skip: + raise ValueError(f"skip must not exceed the configured maximum of {max_skip}.") + dynamic_filter = map_filter(filter, arguments) + results = await search.search( + query, + search_type=search_type, + filter=dynamic_filter, + top=invocation_top, + skip=invocation_skip, + ) + mapped_results: list[Content] = [] + consumed_results = 0 + async for result in results: + if consumed_results >= invocation_top: + break + consumed_results += 1 + mapped = map_result(result) + if isinstance(mapped, str): + mapped_results.append(Content.from_text(mapped)) + elif isinstance(mapped, Content): + mapped_results.append(mapped) + else: + mapped_results.extend(mapped) + return mapped_results + + return FunctionTool( + name=name, + description=description, + approval_mode=approval_mode, + func=search_tool, + input_model=input_model, + ) + + +def _is_non_string_sequence(value: Any) -> TypeGuard[Sequence[Any]]: + return isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray, Mapping)) + + +async def _as_async_iterable( + values: AsyncIterable[ResultT] | Sequence[ResultT], +) -> AsyncIterator[ResultT]: + if isinstance(values, AsyncIterable): + async for value in values: + yield value + return + for value in values: + yield value + + +def _default_search_tool_parameters() -> dict[str, Any]: + return { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The query to search for.", + }, + }, + "required": ["query"], + "additionalProperties": False, + } + + +def _validate_search_tool_parameters( + parameters: type[BaseModel] | Mapping[str, Any], + *, + default_top: int, + default_skip: int, +) -> tuple[int, int]: + schema: Mapping[str, Any] = parameters.model_json_schema() if isinstance(parameters, type) else parameters + raw_properties = schema.get("properties") + if not isinstance(raw_properties, Mapping): + raise ValueError("Search tool parameters must define object properties.") + properties = cast(Mapping[str, Any], raw_properties) + query_schema = properties.get("query") + required = schema.get("required") + query_type = cast(Mapping[str, Any], query_schema).get("type") if isinstance(query_schema, Mapping) else None + if ( + not isinstance(query_schema, Mapping) + or query_type != "string" + or not _is_non_string_sequence(required) + or "query" not in required + ): + raise ValueError("Search tool parameters must define 'query' as a required string.") + + limits = {"top": default_top, "skip": default_skip} + for name, minimum in (("top", 1), ("skip", 0)): + parameter_schema = properties.get(name) + if parameter_schema is None: + continue + if not isinstance(parameter_schema, Mapping): + raise ValueError(f"Search tool parameter '{name}' must be an integer.") + typed_parameter_schema = cast(Mapping[str, Any], parameter_schema) + if typed_parameter_schema.get("type") != "integer": + raise ValueError(f"Search tool parameter '{name}' must be an integer.") + maximum = typed_parameter_schema.get("maximum") + if not isinstance(maximum, int) or isinstance(maximum, bool) or maximum < minimum: + raise ValueError(f"Search tool parameter '{name}' must declare an integer maximum of at least {minimum}.") + configured_default = default_top if name == "top" else default_skip + if configured_default > maximum: + raise ValueError(f"Configured {name}={configured_default} exceeds the parameter maximum of {maximum}.") + limits[name] = maximum + return limits["top"], limits["skip"] + + +def _default_search_filter_mapper( + search_filter: RecordFilters | None, + arguments: Mapping[str, Any], +) -> RecordFilters | None: + dynamic_filters: list[RecordFilter] = [] + for name, value in arguments.items(): + if not name.isidentifier(): + raise ValueError(f"Search tool parameter '{name}' cannot be mapped to a model field.") + dynamic_filters.append(f"lambda record: record.{name} == {value!r}") + if not dynamic_filters: + return search_filter + if search_filter is None: + return dynamic_filters + if _is_non_string_sequence(search_filter) and not callable(search_filter): + return [*cast(Sequence[RecordFilter], search_filter), *dynamic_filters] + return [cast(RecordFilter, search_filter), *dynamic_filters] + + +def _default_search_result_mapper(response: SearchResponse[Any]) -> str: + return msgspec.json.encode( + response, + enc_hook=_msgspec_enc_hook, + ).decode() diff --git a/python/packages/core/tests/core/test_vectors.py b/python/packages/core/tests/core/test_vectors.py new file mode 100644 index 00000000000..9bb045e61d7 --- /dev/null +++ b/python/packages/core/tests/core/test_vectors.py @@ -0,0 +1,1373 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import warnings +from ast import AST, unparse +from collections.abc import AsyncIterable, Mapping, Sequence +from dataclasses import FrozenInstanceError, dataclass, field +from typing import Annotated, Any, ClassVar, cast +from unittest.mock import patch + +import msgspec +import pytest +from pydantic import BaseModel +from pydantic import Field as PydanticField +from typing_extensions import TypeVar + +from agent_framework import ( + DISTANCE_FUNCTION_DIRECTION_HELPER, + BaseEmbeddingClient, + BaseVectorCollection, + BaseVectorSearch, + BaseVectorStore, + Content, + DistanceFunction, + Embedding, + EmbeddingGenerationOptions, + ExperimentalFeature, + FieldTypes, + GeneratedEmbeddings, + IndexKind, + SearchResponse, + SearchResults, + SearchType, + SupportsVectorSearch, + SupportsVectorUpsert, + VectorStoreCollectionDefinition, + VectorStoreField, + create_vector_search_tool, + register_vectorstoremodel, + vectorstoremodel, +) +from agent_framework._feature_stage import ExperimentalWarning +from agent_framework._telemetry import FeatureIndex +from agent_framework._vectors import _VectorStoreRecordHandler as VectorStoreRecordHandler +from agent_framework.exceptions import IntegrationException, IntegrationInvalidResponseException + +pytestmark = pytest.mark.filterwarnings("ignore::agent_framework._feature_stage.ExperimentalWarning") + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", ExperimentalWarning) + RecordVector = Annotated[ + str | list[float] | None, + VectorStoreField( + "vector", + dimensions=2, + index_kind="hnsw", + distance_function="cosine_similarity", + ), + ] + + @vectorstoremodel(collection_name="records") + @dataclass + class Record: + id: Annotated[str, VectorStoreField("key", storage_name="record_id")] + text: Annotated[str, VectorStoreField("data", storage_name="body", is_full_text_indexed=True)] + vector: RecordVector = None + category: str = "general" + + +class MockEmbeddingClient(BaseEmbeddingClient): + def __init__(self) -> None: + super().__init__() + self.values: list[Any] = [] + self.options: EmbeddingGenerationOptions | None = None + + async def get_embeddings( + self, + values: Sequence[Any], + *, + options: EmbeddingGenerationOptions | None = None, + ) -> GeneratedEmbeddings[list[float]]: + self.values = list(values) + self.options = options + return GeneratedEmbeddings([Embedding(vector=[float(len(str(value))), 0.5]) for value in values]) + + +class MockCollection(BaseVectorCollection[str, Record], BaseVectorSearch[str, Record]): + supported_key_types: ClassVar[set[str] | None] = {"str"} + supported_vector_types: ClassVar[set[str] | None] = {"float"} + supported_search_types: ClassVar[set[SearchType]] = {"vector", "keyword_hybrid"} + + def __init__(self, *, embedding_generator: MockEmbeddingClient | None = None) -> None: + super().__init__(Record, embedding_generator=embedding_generator) + self.created = False + self.records: dict[str, dict[str, Any]] = {} + self.last_search_type: str | None = None + self.last_search_vector: Sequence[float | int] | None = None + self.last_search_filter: Any | list[Any] | None = None + self.last_search_top = 0 + self.last_search_skip = 0 + self.fail_upsert = False + self.upsert_error: Exception | None = None + self.get_error: Exception | None = None + self.delete_error: Exception | None = None + self.search_error: Exception | None = None + self.upsert_keys: Sequence[str] | None = None + self.raw_search_results: AsyncIterable[Any] | Sequence[Any] | None = None + + async def ensure_collection_exists( + self, + *, + operation_options: Mapping[str, Any] | None = None, + ) -> None: + self.created = True + + async def collection_exists( + self, + *, + operation_options: Mapping[str, Any] | None = None, + ) -> bool: + return self.created + + async def ensure_collection_deleted( + self, + *, + operation_options: Mapping[str, Any] | None = None, + ) -> None: + self.created = False + self.records.clear() + + async def _inner_upsert( + self, + records: Sequence[Any], + *, + operation_options: Mapping[str, Any] | None = None, + ) -> Sequence[str]: + if self.upsert_error is not None: + raise self.upsert_error + if self.fail_upsert: + raise RuntimeError("store unavailable") + keys: list[str] = [] + for record in records: + mapping = cast(Mapping[str, Any], record) + key = cast(str, mapping["record_id"]) + self.records[key] = dict(mapping) + keys.append(key) + return self.upsert_keys if self.upsert_keys is not None else keys + + async def _inner_get( + self, + *, + keys: Sequence[str] | None = None, + top: int = 10, + skip: int = 0, + order_by: Mapping[str, bool] | None = None, + include_vectors: bool = False, + operation_options: Mapping[str, Any] | None = None, + ) -> Sequence[Any] | None: + if self.get_error is not None: + raise self.get_error + if keys is not None: + return [self.records[key] for key in keys if key in self.records] + return list(self.records.values())[skip : skip + top] + + async def _inner_delete( + self, + keys: Sequence[str], + *, + operation_options: Mapping[str, Any] | None = None, + ) -> None: + if self.delete_error is not None: + raise self.delete_error + for key in keys: + self.records.pop(key, None) + + async def _inner_search( + self, + *, + search_type: SearchType, + filter: Any | list[Any] | None = None, + values: Any | None = None, + vector: Sequence[float | int] | None = None, + top: int = 3, + skip: int = 0, + include_vectors: bool = False, + vector_property_name: str | None = None, + additional_property_name: str | None = None, + score_threshold: float | None = None, + operation_options: Mapping[str, Any] | None = None, + ) -> SearchResults[Any]: + if self.search_error is not None: + raise self.search_error + self.last_search_type = search_type + self.last_search_vector = vector + self.last_search_filter = filter + self.last_search_top = top + self.last_search_skip = skip + raw_results = self.raw_search_results or [ + {"record": record, "score": score} for record, score in zip(self.records.values(), (0.9, 0.4), strict=False) + ] + return SearchResults(raw_results, metadata={"mock_count": len(self.records)}) + + def _get_record_from_result(self, result: Any) -> Any: + return result["record"] + + def _get_score_from_result(self, result: Any) -> float | None: + return cast(float | None, result["score"]) + + def _lambda_parser(self, node: AST) -> str: + return unparse(node) + + +StoreModelT = TypeVar("StoreModelT") + + +class MockStore(BaseVectorStore): + def __init__(self, collection: MockCollection) -> None: + super().__init__() + self.collection = collection + + def get_collection( + self, + record_type: type[StoreModelT], + *, + definition: VectorStoreCollectionDefinition | None = None, + collection_name: str | None = None, + embedding_generator: Any | None = None, + ) -> BaseVectorCollection[Any, StoreModelT]: + return cast(BaseVectorCollection[Any, StoreModelT], self.collection) + + async def list_collection_names( + self, + *, + operation_options: Mapping[str, Any] | None = None, + ) -> Sequence[str]: + return [self.collection.collection_name] if self.collection.created else [] + + async def _inner_ensure_collection_deleted( + self, + collection_name: str, + *, + operation_options: Mapping[str, Any] | None = None, + ) -> None: + assert collection_name == self.collection.collection_name + await self.collection.ensure_collection_deleted(operation_options=operation_options) + + +def test_vector_literal_types_and_distance_directions() -> None: + field_type: FieldTypes = "vector" + index_kind: IndexKind = "hnsw" + distance_function: DistanceFunction = "cosine_similarity" + + assert field_type == "vector" + assert index_kind == "hnsw" + assert distance_function == "cosine_similarity" + assert DISTANCE_FUNCTION_DIRECTION_HELPER["cosine_similarity"](0.5, 0.5) + assert DISTANCE_FUNCTION_DIRECTION_HELPER["cosine_distance"](0.5, 0.5) + assert not DISTANCE_FUNCTION_DIRECTION_HELPER["cosine_distance"](0.6, 0.5) + + +def test_vector_apis_are_marked_experimental() -> None: + staged_apis = ( + VectorStoreField, + VectorStoreCollectionDefinition, + vectorstoremodel, + SearchResponse, + SearchResults, + BaseVectorCollection, + BaseVectorStore, + BaseVectorSearch, + register_vectorstoremodel, + ) + for api in staged_apis: + assert getattr(api, "__feature_stage__", None) == "experimental" + assert getattr(api, "__feature_id__", None) == ExperimentalFeature.VECTOR_STORES.value + assert ".. warning:: Experimental" in (api.__doc__ or "") + + staged_protocols = ( + SupportsVectorUpsert, + SupportsVectorSearch, + ) + for protocol in staged_protocols: + assert ".. warning:: Experimental" in (protocol.__doc__ or "") + + +def test_vector_field_validates_vector_options() -> None: + with pytest.raises(ValueError, match="positive"): + cast(Any, VectorStoreField)("vector") + with pytest.raises(ValueError, match="Vector-only"): + cast(Any, VectorStoreField)("data", dimensions=3) + with pytest.raises(ValueError, match="index kind"): + cast(Any, VectorStoreField)("vector", dimensions=3, index_kind="unknown") + with pytest.raises(ValueError, match="distance function"): + cast(Any, VectorStoreField)("vector", dimensions=3, distance_function="unknown") + + +def test_collection_definition_exposes_fields() -> None: + definition = cast(VectorStoreCollectionDefinition, vars(Record)["__vectorstoremodel_definition__"]) + + assert definition.collection_name == "records" + assert definition.key_name == "id" + assert definition.key_field_storage_name == "record_id" + assert definition.names == ["id", "text", "vector"] + assert definition.storage_names == ["record_id", "body", "vector"] + assert definition.data_field_names == ["text"] + assert definition.vector_field_names == ["vector"] + assert definition.get_names(include_vector_fields=False) == ["id", "text"] + assert definition.get_storage_names(include_key_field=False) == ["body", "vector"] + assert isinstance(definition.fields, tuple) + assert definition.vector_fields[0].dimensions == 2 + assert definition.vector_fields[0].index_kind == "hnsw" + assert definition.vector_fields[0].distance_function == "cosine_similarity" + + frozen_field = cast(Any, definition.fields[0]) + with pytest.raises(FrozenInstanceError): + frozen_field.name = "changed" + frozen_definition = cast(Any, definition) + with pytest.raises(FrozenInstanceError): + frozen_definition.fields = () + + +@pytest.mark.parametrize( + "fields, message", + [ + ([], "at least one"), + ([VectorStoreField("data", name="text")], "exactly one key"), + ( + [ + VectorStoreField("key", name="id"), + VectorStoreField("key", name="other_id"), + ], + "exactly one key", + ), + ( + [ + VectorStoreField("key", name="id"), + VectorStoreField("data", name="id"), + ], + "must be unique", + ), + ], +) +def test_collection_definition_rejects_invalid_fields( + fields: list[VectorStoreField], + message: str, +) -> None: + with pytest.raises(ValueError, match=message): + VectorStoreCollectionDefinition(fields) + + +def test_vectorstoremodel_supports_pydantic_models() -> None: + @vectorstoremodel + class PydanticRecord(BaseModel): + id: Annotated[str, VectorStoreField("key")] + vector: Annotated[list[float] | None, VectorStoreField("vector", dimensions=2)] = None + + definition = cast( + VectorStoreCollectionDefinition, + vars(PydanticRecord)["__vectorstoremodel_definition__"], + ) + assert vars(PydanticRecord)["__vectorstoremodel__"] + assert definition.key_field.type_ == "str" + assert definition.vector_fields[0].type_ == "float" + handler = VectorStoreRecordHandler(PydanticRecord) + record = handler.deserialize({"id": "one", "vector": [1.0, 0.0]}, include_vectors=False) + assert isinstance(record, PydanticRecord) + assert record.vector is None + + +def test_vectorstoremodel_supports_plain_classes() -> None: + @vectorstoremodel + class PlainRecord: + def __init__( + self, + id: Annotated[str, VectorStoreField("key")], + text: Annotated[str, VectorStoreField("data")], + ) -> None: + self.id = id + self.text = text + + definition = cast( + VectorStoreCollectionDefinition, + vars(PlainRecord)["__vectorstoremodel_definition__"], + ) + assert definition.names == ["id", "text"] + + +def test_vectorstoremodel_ignores_fields_with_defaults() -> None: + assert ( + "category" + not in cast( + VectorStoreCollectionDefinition, + vars(Record)["__vectorstoremodel_definition__"], + ).names + ) + + +def test_vectorstoremodel_detects_factory_and_required_slotted_defaults() -> None: + @vectorstoremodel + @dataclass(slots=True) + class FactoryRecord: + id: Annotated[str, VectorStoreField("key")] + ignored: list[str] = field(default_factory=list) + + assert ( + "ignored" + not in cast( + VectorStoreCollectionDefinition, + vars(FactoryRecord)["__vectorstoremodel_definition__"], + ).names + ) + + class InvalidStruct(msgspec.Struct): + id: Annotated[str, VectorStoreField("key")] + required_but_unmapped: str + + with pytest.raises(ValueError, match="required_but_unmapped"): + vectorstoremodel(InvalidStruct) + + class RequiredVector(msgspec.Struct): + id: Annotated[str, VectorStoreField("key")] + vector: Annotated[list[float], VectorStoreField("vector", dimensions=2)] + + with pytest.raises(ValueError, match="must declare defaults"): + vectorstoremodel(RequiredVector) + + +def test_vectorstoremodel_rejects_required_unmapped_fields() -> None: + class InvalidRecord: + id: Annotated[str, VectorStoreField("key")] + required_but_unmapped: str + + with pytest.raises(ValueError, match="required_but_unmapped"): + vectorstoremodel(InvalidRecord) + assert not hasattr(InvalidRecord, "__vectorstoremodel__") + + +async def test_collection_and_search_validate_paging() -> None: + with pytest.raises(ValueError, match="greater than zero"): + await MockCollection().get(top=0) + with pytest.raises(ValueError, match="negative"): + await MockCollection().search("query", skip=-1) + + +def test_record_handler_validates_connector_field_types() -> None: + class IntKeyHandler(VectorStoreRecordHandler[str, Record]): + supported_key_types: ClassVar[set[str] | None] = {"int"} + + with pytest.raises(ValueError, match="Key field type"): + IntKeyHandler(Record) + + +async def test_record_handler_serializes_dict_records_with_explicit_definition() -> None: + definition = VectorStoreCollectionDefinition([ + VectorStoreField("key", name="id", storage_name="record_id"), + VectorStoreField("data", name="text", storage_name="body"), + ]) + handler = VectorStoreRecordHandler(dict, definition=definition) + + serialized = await handler.serialize({"id": "one", "text": "hello"}) + assert serialized == {"record_id": "one", "body": "hello"} + assert handler.deserialize(serialized) == {"id": "one", "text": "hello"} + assert handler.deserialize([]) == [] + + with pytest.raises(IntegrationInvalidResponseException, match="missing required field 'body'"): + handler.deserialize({"record_id": "one"}) + assert handler.deserialize({"record_id": "one", "body": None}) == {"id": "one", "text": None} + + with pytest.raises(ValueError, match="missing.*text"): + await handler.serialize({"id": "missing-text"}) + + +async def test_batch_serializer_preserves_cardinality() -> None: + class DroppingHandler(VectorStoreRecordHandler[Any, Record]): + def _serialize_dicts_to_store_models( + self, + records: Sequence[dict[str, Any]], + *, + context: Mapping[str, Any] | None = None, + ) -> Sequence[Any]: + return records[:-1] + + with pytest.raises(IntegrationInvalidResponseException, match="Expected 2 serialized records"): + await DroppingHandler(Record).serialize( + [ + Record("one", "first"), + Record("two", "second"), + ], + generate_vectors=False, + ) + + +async def test_record_handler_supports_msgspec_structs() -> None: + @vectorstoremodel + class MsgspecRecord(msgspec.Struct): + id: Annotated[str, VectorStoreField("key")] + vector: Annotated[list[float] | None, VectorStoreField("vector", dimensions=2)] = None + + handler = VectorStoreRecordHandler(MsgspecRecord) + serialized = await handler.serialize(MsgspecRecord("one", [1.0, 0.0]), generate_vectors=False) + deserialized = handler.deserialize(serialized) + + assert serialized == {"id": "one", "vector": [1.0, 0.0]} + assert deserialized == MsgspecRecord("one", [1.0, 0.0]) + + +async def test_record_handler_uses_registered_codecs() -> None: + @dataclass + class CustomRecord: + id: str + text: str + + definition = VectorStoreCollectionDefinition( + [ + VectorStoreField("key", name="id", storage_name="record_id"), + VectorStoreField("data", name="text", storage_name="body"), + ], + ) + register_vectorstoremodel( + CustomRecord, + definition=definition, + encoder=lambda record: {"id": record.id, "text": record.text.upper()}, + decoder=lambda record: CustomRecord(**record), + ) + handler = VectorStoreRecordHandler(CustomRecord) + + serialized = await handler.serialize(CustomRecord("one", "hello")) + assert serialized == {"record_id": "one", "body": "HELLO"} + assert handler.deserialize(serialized) == CustomRecord("one", "HELLO") + + +async def test_register_vectorstoremodel_supports_independent_encoder_override() -> None: + @dataclass + class RegisteredRecord: + id: str = "" + + definition = VectorStoreCollectionDefinition([VectorStoreField("key", name="id")]) + + def encoder(record: RegisteredRecord) -> Mapping[str, Any]: + return {"id": record.id} + + register_vectorstoremodel(RegisteredRecord, definition=definition, encoder=encoder) + handler = VectorStoreRecordHandler(RegisteredRecord) + assert await handler.serialize(RegisteredRecord("one")) == {"id": "one"} + assert handler.deserialize({"id": "one"}) == RegisteredRecord("one") + + with pytest.raises(ValueError, match="another definition"): + register_vectorstoremodel( + RegisteredRecord, + definition=VectorStoreCollectionDefinition([VectorStoreField("key", name="other_id")]), + ) + + +async def test_array_like_vectors_round_trip_without_array_dependency() -> None: + class ArrayLike: + __slots__ = ("values",) + + def __init__(self, values: list[float]) -> None: + self.values = values + + def tolist(self) -> list[float]: + return self.values + + def decode_array_record(record: Mapping[str, Any]) -> ArrayRecord: + return ArrayRecord( + id=cast(str, record["id"]), + vector=ArrayLike(cast(list[float], record["vector"])), + ) + + @vectorstoremodel(decoder=decode_array_record) + @dataclass + class ArrayRecord: + id: Annotated[str, VectorStoreField("key")] + vector: Annotated[Any, VectorStoreField("vector", dimensions=3)] + + handler = VectorStoreRecordHandler(ArrayRecord) + serialized = await handler.serialize( + ArrayRecord("one", ArrayLike([0.1, 0.2, 0.3])), + generate_vectors=False, + ) + restored = handler.deserialize(serialized) + + assert serialized == {"id": "one", "vector": [0.1, 0.2, 0.3]} + assert isinstance(restored, ArrayRecord) + assert restored.vector.values == [0.1, 0.2, 0.3] + + +async def test_custom_encoder_normalizes_array_like_vectors() -> None: + class ArrayLike: + def tolist(self) -> list[float]: + return [0.1, 0.2, 0.3] + + @dataclass + class CustomArrayRecord: + id: str + vector: ArrayLike + + definition = VectorStoreCollectionDefinition([ + VectorStoreField("key", name="id"), + VectorStoreField("vector", name="vector", dimensions=3), + ]) + register_vectorstoremodel( + CustomArrayRecord, + definition=definition, + encoder=lambda record: {"id": record.id, "vector": record.vector}, + decoder=lambda record: CustomArrayRecord( + id=cast(str, record["id"]), + vector=ArrayLike(), + ), + ) + + serialized = await VectorStoreRecordHandler(CustomArrayRecord).serialize( + CustomArrayRecord("one", ArrayLike()), + generate_vectors=False, + ) + assert serialized == {"id": "one", "vector": [0.1, 0.2, 0.3]} + + +async def test_pydantic_aliases_round_trip_by_field_name() -> None: + @vectorstoremodel + class AliasedRecord(BaseModel): + id: Annotated[str, PydanticField(alias="record_id"), VectorStoreField("key")] + + handler = VectorStoreRecordHandler(AliasedRecord) + serialized = await handler.serialize(AliasedRecord.model_validate({"record_id": "one"})) + restored = handler.deserialize(serialized) + + assert serialized == {"id": "one"} + assert isinstance(restored, AliasedRecord) + assert restored.id == "one" + + +async def test_collection_serializes_records_and_generates_vectors() -> None: + embedding_client = MockEmbeddingClient() + collection = MockCollection(embedding_generator=embedding_client) + + serialized = await collection.serialize(Record("one", "hello", "embed this")) + + assert serialized == { + "record_id": "one", + "body": "hello", + "vector": [10.0, 0.5], + } + assert embedding_client.values == ["embed this"] + assert embedding_client.options == {"dimensions": 2} + + +async def test_upsert_controls_embedding_generation() -> None: + embedding_client = MockEmbeddingClient() + collection = MockCollection(embedding_generator=embedding_client) + + await collection.upsert([Record("generated", "text", [1.0, 0.0])]) + + assert embedding_client.values == [[1.0, 0.0]] + assert collection.records["generated"]["vector"] == [10.0, 0.5] + + embedding_client.values.clear() + await collection.upsert( + [Record("preserved", "text", [1.0, 0.0])], + generate_vectors=False, + ) + + assert embedding_client.values == [] + assert collection.records["preserved"]["vector"] == [1.0, 0.0] + + with pytest.raises(ValueError, match="has no embedding generator.*generate_vectors=False"): + await MockCollection().upsert([Record("missing-generator", "text", [1.0, 0.0])]) + + +async def test_collection_crud_preserves_single_and_batch_shapes() -> None: + collection = MockCollection(embedding_generator=MockEmbeddingClient()) + await collection.ensure_collection_exists() + + first_keys = await collection.upsert([Record("one", "first", "first")]) + keys = await collection.upsert([ + Record("two", "second", "second"), + Record("three", "third", "third"), + ]) + one = await collection.get(["one"]) + many = await collection.get(["one", "two"], include_vectors=True) + filtered = await collection.get(top=1) + + assert first_keys == ["one"] + assert keys == ["two", "three"] + assert one == [Record("one", "first")] + assert many == [ + Record("one", "first", [5.0, 0.5]), + Record("two", "second", [6.0, 0.5]), + ] + assert filtered == [Record("one", "first")] + + await collection.delete(["one", "two"]) + assert await collection.get(["one", "two"]) == [] + + +async def test_collection_wraps_connector_errors() -> None: + collection = MockCollection() + collection.fail_upsert = True + + with pytest.raises(IntegrationException, match="store unavailable"): + await collection.upsert([Record("one", "hello")], generate_vectors=False) + + +async def test_collection_get_without_keys_lists_records() -> None: + assert await MockCollection().get() == [] + + +async def test_collection_crud_rejects_singular_ordinary_inputs() -> None: + collection = MockCollection() + + with pytest.raises(TypeError, match="records must be a sequence"): + await cast(Any, collection.upsert)(Record("one", "hello")) + with pytest.raises(TypeError, match="keys must be a sequence"): + await collection.get("one") + with pytest.raises(TypeError, match="keys must be a sequence"): + await collection.delete("one") + + +async def test_vector_search_generates_query_vector_and_filters_threshold() -> None: + embedding_client = MockEmbeddingClient() + collection = MockCollection(embedding_generator=embedding_client) + await collection.upsert([ + Record("one", "first", "first"), + Record("two", "second", "second"), + ]) + + results = await collection.search( + "find this", + score_threshold=0.5, + ) + responses = [response async for response in results] + + assert results.metadata == {"mock_count": 2} + assert embedding_client.values == ["find this"] + assert collection.last_search_vector == [9.0, 0.5] + assert responses[0]["record"].id == "one" + assert responses[0]["score"] == 0.9 + assert len(responses) == 1 + + +async def test_keyword_hybrid_search_uses_single_search_method() -> None: + collection = MockCollection() + + await collection.search("words", search_type="keyword_hybrid") + + assert collection.last_search_type == "keyword_hybrid" + + +async def test_vector_search_validates_inputs_and_supported_type() -> None: + collection = MockCollection() + + with pytest.raises(ValueError, match="requires values"): + await cast(Any, collection.search)() + + class VectorOnlyCollection(MockCollection): + supported_search_types: ClassVar[set[SearchType]] = {"vector"} + + with pytest.raises(NotImplementedError, match="not supported"): + await VectorOnlyCollection().search("words", search_type="keyword_hybrid") + + +async def test_vector_search_requires_explicit_distance_for_score_threshold() -> None: + collection = MockCollection() + collection.definition = VectorStoreCollectionDefinition( + [ + VectorStoreField("key", name="id", type_="str"), + VectorStoreField("vector", name="vector", type_="float", dimensions=2), + ], + collection_name="records", + ) + + with pytest.raises(ValueError, match="explicit distance"): + await collection.search(vector=[1.0, 0.0], score_threshold=0.5) + + +async def test_vector_search_wraps_embedding_failures() -> None: + class FailingEmbeddingClient(MockEmbeddingClient): + async def get_embeddings( + self, + values: Sequence[Any], + *, + options: EmbeddingGenerationOptions | None = None, + ) -> GeneratedEmbeddings[list[float]]: + raise RuntimeError("embedding unavailable") + + collection = MockCollection(embedding_generator=FailingEmbeddingClient()) + + with pytest.raises(IntegrationException, match="embedding unavailable"): + await collection.search("query") + + +def test_vector_search_builds_connector_filter() -> None: + collection = MockCollection() + + assert collection._build_filter("lambda record: record.category == 'travel'") == "record.category == 'travel'" + assert collection._build_filter([ + "lambda record: record.category == 'travel'", + "lambda record: record.id != 'ignored'", + ]) == ["record.category == 'travel'", "record.id != 'ignored'"] + + +async def test_vector_search_passes_translated_filter_to_connector() -> None: + collection = MockCollection() + + await collection.search( + "query", + filter="lambda record: record.category == 'travel'", + ) + + assert collection.last_search_filter == "record.category == 'travel'" + + +def test_vector_search_rejects_filter_without_lambda() -> None: + with pytest.raises(ValueError, match="No lambda"): + MockCollection()._build_filter("record.category == 'travel'") + + +async def test_create_search_tool_returns_mapped_results() -> None: + collection = MockCollection() + collection.records["one"] = {"record_id": "one", "body": "first", "vector": [1.0, 0.0]} + tool = create_vector_search_tool( + collection, + name="search_records", + approval_mode="always_require", + top=1, + result_mapper=lambda response: f"{response['record'].id}:{response['score']}", + ) + + result = await tool(query="first") + + assert tool.name == "search_records" + assert tool.approval_mode == "always_require" + assert len(result) == 1 + assert result[0].text == "one:0.9" + + +async def test_create_search_tool_supports_declared_filter_parameters() -> None: + collection = MockCollection() + collection.records["one"] = {"record_id": "one", "body": "first", "vector": [1.0, 0.0]} + tool = create_vector_search_tool( + collection, + parameters={ + "type": "object", + "properties": { + "query": {"type": "string", "description": "The search query."}, + "category": {"type": "string", "description": "The category to match."}, + "top": { + "type": "integer", + "description": "The maximum number of results.", + "maximum": 5, + }, + "skip": { + "type": "integer", + "description": "The number of results to skip.", + "maximum": 10, + }, + }, + "required": ["query", "category"], + "additionalProperties": False, + }, + ) + + await tool(query="first", category="travel", top=1, skip=2) + + assert set(tool.parameters()["properties"]) == {"query", "category", "top", "skip"} + assert collection.last_search_filter == "record.category == 'travel'" + assert collection.last_search_top == 1 + assert collection.last_search_skip == 2 + + +def test_create_search_tool_validates_custom_schema() -> None: + collection = MockCollection() + + with pytest.raises(ValueError, match="required string"): + create_vector_search_tool(collection, parameters={"type": "object", "properties": {}}) + with pytest.raises(ValueError, match="required string"): + create_vector_search_tool( + collection, + parameters={ + "type": "object", + "properties": {"query": {"type": "integer"}}, + "required": ["query"], + }, + ) + with pytest.raises(ValueError, match="declare an integer maximum"): + create_vector_search_tool( + collection, + parameters={ + "type": "object", + "properties": { + "query": {"type": "string"}, + "top": {"type": "integer"}, + }, + "required": ["query"], + }, + ) + + +async def test_create_search_tool_enforces_paging_limits_and_result_cap() -> None: + collection = MockCollection() + collection.raw_search_results = [ + {"record": {"record_id": str(index), "body": f"record {index}"}, "score": 0.9} for index in range(3) + ] + tool = create_vector_search_tool( + collection, + top=2, + parameters={ + "type": "object", + "properties": { + "query": {"type": "string"}, + "top": {"type": "integer", "maximum": 2}, + "skip": {"type": "integer", "maximum": 4}, + }, + "required": ["query"], + }, + ) + + results = await tool(query="records", top=2, skip=4) + assert len(results) == 2 + + with pytest.raises(ValueError, match="top must not exceed"): + await tool(query="records", top=3) + with pytest.raises(ValueError, match="skip must not exceed"): + await tool(query="records", skip=5) + + +async def test_create_search_tool_supports_multimodal_results() -> None: + collection = MockCollection() + collection.records["one"] = {"record_id": "one", "body": "first", "vector": [1.0, 0.0]} + tool = create_vector_search_tool( + collection, + top=1, + result_mapper=lambda response: [ + Content.from_text(response["record"].text), + Content.from_uri("https://example.com/result.png", media_type="image/png"), + ], + ) + + result = await tool.invoke(arguments={"query": "first"}) + + assert [content.type for content in result] == ["text", "uri"] + + +async def test_create_search_tool_uses_msgspec_for_default_result_mapping() -> None: + collection = MockCollection() + collection.records["one"] = {"record_id": "one", "body": "first", "vector": [1.0, 0.0]} + + result = await create_vector_search_tool(collection, top=1)(query="first") + + assert result[0].text is not None + decoded = msgspec.json.decode(result[0].text) + assert set(create_vector_search_tool(collection).parameters()["properties"]) == {"query"} + assert decoded["record"]["id"] == "one" + assert decoded["score"] == 0.9 + + +async def test_create_search_tool_defers_unsupported_type_to_search() -> None: + class VectorOnlyCollection(MockCollection): + supported_search_types: ClassVar[set[SearchType]] = {"vector"} + + tool = create_vector_search_tool(VectorOnlyCollection(), search_type="keyword_hybrid") + with pytest.raises(NotImplementedError, match="not supported"): + await tool(query="query") + + +def test_search_protocol_and_tool_factory_only_require_search() -> None: + class SearchOnly: + async def search( + self, + values: Any, + *, + search_type: SearchType = "vector", + vector: Sequence[float | int] | None = None, + filter: Any = None, + top: int = 3, + skip: int = 0, + include_vectors: bool = False, + vector_property_name: str | None = None, + additional_property_name: str | None = None, + score_threshold: float | None = None, + operation_options: Mapping[str, Any] | None = None, + ) -> SearchResults[SearchResponse[Record]]: + return SearchResults([]) + + search = SearchOnly() + assert isinstance(cast(Any, search), SupportsVectorSearch) + assert create_vector_search_tool(cast(SupportsVectorSearch[Record], search)).name == "search" + + +def test_collection_satisfies_vector_protocols() -> None: + collection = MockCollection() + + assert isinstance(collection, SupportsVectorUpsert) + assert isinstance(collection, SupportsVectorSearch) + + +async def test_vector_store_collection_lifecycle_helpers() -> None: + collection = MockCollection() + store = MockStore(collection) + + assert not await store.collection_exists("records") + await collection.ensure_collection_exists() + assert await store.collection_exists("records") + await store.ensure_collection_deleted("records") + assert not await store.collection_exists("records") + + +def test_search_response_holds_record_and_score() -> None: + record = Record("one", "hello") + response = SearchResponse(record=record, score=0.75) + + assert response["record"] is record + assert response["score"] == 0.75 + + +def test_deserialization_rejects_non_mapping_store_records() -> None: + handler = VectorStoreRecordHandler(Record) + + with pytest.raises(TypeError, match="must be mappings"): + handler.deserialize(object()) + + +def test_additional_field_and_definition_validation_paths() -> None: + with pytest.raises(ValueError, match="Unknown vector store field type"): + cast(Any, VectorStoreField)("unknown") + with pytest.raises(ValueError, match="must not be empty"): + VectorStoreCollectionDefinition([VectorStoreField("key")]) + with pytest.raises(ValueError, match="storage names must be unique"): + VectorStoreCollectionDefinition([ + VectorStoreField("key", name="id", storage_name="same"), + VectorStoreField("data", name="text", storage_name="same"), + ]) + + definition = cast(VectorStoreCollectionDefinition, vars(Record)["__vectorstoremodel_definition__"]) + assert definition.try_get_vector_field("vector") is definition.vector_fields[0] + assert definition.try_get_vector_field("missing") is None + + +async def test_default_codecs_cover_pydantic_plain_and_unsupported_models() -> None: + @vectorstoremodel + class PydanticRecord(BaseModel): + id: Annotated[str, VectorStoreField("key")] + + @vectorstoremodel + class PlainRecord: + id: Annotated[str, VectorStoreField("key")] + + def __init__(self, id: str) -> None: + self.id = id + + @vectorstoremodel + class SlottedRecord: + __slots__ = ("id",) + id: Annotated[str, VectorStoreField("key")] + + def __init__(self, id: str) -> None: + self.id = id + + assert await VectorStoreRecordHandler(PydanticRecord).serialize(PydanticRecord(id="one")) == {"id": "one"} + assert await VectorStoreRecordHandler(PlainRecord).serialize(PlainRecord("one")) == {"id": "one"} + with pytest.raises(NotImplementedError, match="SlottedRecord"): + await VectorStoreRecordHandler(SlottedRecord).serialize(SlottedRecord("one")) + + +def test_vectorstoremodel_rejects_unresolvable_or_missing_annotations() -> None: + class UnresolvableRecord: + __annotations__ = {"id": "MissingRecordType"} + + class EmptyRecord: + pass + + with pytest.raises(ValueError, match="Unable to resolve"): + vectorstoremodel(UnresolvableRecord) + with pytest.raises(ValueError, match="at least one annotated field"): + vectorstoremodel(EmptyRecord) + + +def test_registration_is_idempotent_and_rejects_changed_codecs() -> None: + @dataclass + class RegisteredRecord: + id: str + + definition = VectorStoreCollectionDefinition([VectorStoreField("key", name="id")]) + + def encoder(record: RegisteredRecord) -> Mapping[str, Any]: + return {"id": record.id} + + def decoder(record: Mapping[str, Any]) -> RegisteredRecord: + return RegisteredRecord(cast(str, record["id"])) + + register_vectorstoremodel(RegisteredRecord, definition=definition, encoder=encoder, decoder=decoder) + register_vectorstoremodel(RegisteredRecord, definition=definition, encoder=encoder, decoder=decoder) + + with pytest.raises(ValueError, match="another encoder"): + register_vectorstoremodel( + RegisteredRecord, + definition=definition, + encoder=lambda record: {"id": record.id}, + decoder=decoder, + ) + with pytest.raises(ValueError, match="another decoder"): + register_vectorstoremodel( + RegisteredRecord, + definition=definition, + encoder=encoder, + decoder=lambda record: RegisteredRecord(cast(str, record["id"])), + ) + + +def test_record_handler_requires_registered_models_or_explicit_dict_definitions() -> None: + class UnregisteredRecord: + pass + + with pytest.raises(ValueError, match="explicit"): + VectorStoreRecordHandler(dict) + with pytest.raises(ValueError, match="must be registered"): + VectorStoreRecordHandler(UnregisteredRecord) + + other_definition = VectorStoreCollectionDefinition([VectorStoreField("key", name="other_id")]) + with pytest.raises(ValueError, match="another definition"): + VectorStoreRecordHandler(Record, definition=other_definition) + + +async def test_serialization_shape_and_embedding_failures() -> None: + definition = VectorStoreCollectionDefinition([ + VectorStoreField("key", name="id", storage_name="record_id"), + VectorStoreField("data", name="text", storage_name="body"), + ]) + dict_handler = VectorStoreRecordHandler(dict, definition=definition) + assert await dict_handler.serialize({"record_id": "one", "body": "hello"}) == { + "record_id": "one", + "body": "hello", + } + with pytest.raises(TypeError, match="must serialize to mappings"): + await dict_handler.serialize(cast(Any, 1)) + + collection = MockCollection(embedding_generator=MockEmbeddingClient()) + with pytest.raises(ValueError, match="value is missing"): + await collection.serialize(Record("one", "hello")) + + class EmptyEmbeddingClient(MockEmbeddingClient): + async def get_embeddings( + self, + values: Sequence[Any], + *, + options: EmbeddingGenerationOptions | None = None, + ) -> GeneratedEmbeddings[list[float]]: + return GeneratedEmbeddings() + + with pytest.raises(IntegrationInvalidResponseException, match="returned 0 vectors"): + await MockCollection(embedding_generator=EmptyEmbeddingClient()).serialize(Record("one", "hello", "embed")) + + assert dict_handler.deserialize(None) is None + + +async def test_array_like_generated_embeddings_are_normalized() -> None: + class ArrayLike: + def tolist(self) -> list[float]: + return [0.1, 0.2] + + class ArrayEmbeddingClient(MockEmbeddingClient): + async def get_embeddings( + self, + values: Sequence[Any], + *, + options: EmbeddingGenerationOptions | None = None, + ) -> GeneratedEmbeddings[Any]: + return GeneratedEmbeddings([Embedding(vector=ArrayLike()) for _ in values]) + + collection = MockCollection(embedding_generator=ArrayEmbeddingClient()) + serialized = await collection.serialize(Record("one", "hello", "embed")) + assert serialized["vector"] == [0.1, 0.2] + + results = await collection.search("query") + assert collection.last_search_vector == [0.1, 0.2] + assert [result async for result in results] == [] + + +async def test_collection_operation_error_boundaries_and_context_manager() -> None: + collection = MockCollection() + async with collection as entered: + assert entered is collection + + collection.upsert_error = IntegrationException("known upsert failure") + with pytest.raises(IntegrationException, match="known upsert failure"): + await collection.upsert([Record("one", "hello")], generate_vectors=False) + collection.upsert_error = None + collection.upsert_keys = [] + with pytest.raises(IntegrationInvalidResponseException, match="Expected 1 upserted keys"): + await collection.upsert([Record("one", "hello")], generate_vectors=False) + + collection.get_error = RuntimeError("get failure") + with pytest.raises(IntegrationException, match="get failure"): + await collection.get(["one"]) + collection.get_error = None + collection.delete_error = IntegrationException("known delete failure") + with pytest.raises(IntegrationException, match="known delete failure"): + await collection.delete(["one"]) + collection.delete_error = RuntimeError("delete failure") + with pytest.raises(IntegrationException, match="delete failure"): + await collection.delete(["one"]) + + class FailingEmbeddingClient(MockEmbeddingClient): + async def get_embeddings( + self, + values: Sequence[Any], + *, + options: EmbeddingGenerationOptions | None = None, + ) -> GeneratedEmbeddings[list[float]]: + raise RuntimeError("embedding down") + + with pytest.raises(IntegrationException, match="embedding down"): + await MockCollection(embedding_generator=FailingEmbeddingClient()).upsert([Record("one", "hello", "embed")]) + + +async def test_vector_store_context_and_missing_collection_delete() -> None: + collection = MockCollection() + store = MockStore(collection) + + async with store as entered: + assert entered is store + await store.ensure_collection_deleted("missing") + assert not collection.created + + +async def test_additional_search_validation_and_error_boundaries() -> None: + collection = MockCollection() + with pytest.raises(ValueError, match="Unknown search type"): + await collection.search("query", search_type=cast(Any, "unknown")) + with pytest.raises(ValueError, match="Keyword-hybrid"): + await cast(Any, collection.search)(search_type="keyword_hybrid", vector=[1.0, 0.0]) + with pytest.raises(ValueError, match="was not found"): + await collection.search("query", vector_property_name="missing") + + collection.search_error = IntegrationException("known search failure") + with pytest.raises(IntegrationException, match="known search failure"): + await collection.search("query") + collection.search_error = RuntimeError("search failure") + with pytest.raises(IntegrationException, match="search failure"): + await collection.search("query") + + +async def test_search_embedding_and_result_conversion_failures() -> None: + class EmptyEmbeddingClient(MockEmbeddingClient): + async def get_embeddings( + self, + values: Sequence[Any], + *, + options: EmbeddingGenerationOptions | None = None, + ) -> GeneratedEmbeddings[list[float]]: + return GeneratedEmbeddings() + + class StringEmbeddingClient(MockEmbeddingClient): + async def get_embeddings( + self, + values: Sequence[Any], + *, + options: EmbeddingGenerationOptions | None = None, + ) -> GeneratedEmbeddings[Any]: + return GeneratedEmbeddings([Embedding(vector="invalid")]) + + with pytest.raises(IntegrationInvalidResponseException, match="returned 0 vectors"): + await MockCollection(embedding_generator=EmptyEmbeddingClient()).search("query") + with pytest.raises(TypeError, match="unsupported vector type"): + await MockCollection(embedding_generator=StringEmbeddingClient()).search("query") + + collection = MockCollection() + collection.raw_search_results = [{"record": None, "score": 0.9}] + results = await collection.search(vector=[1.0, 0.0]) + assert [result async for result in results] == [] + + collection.raw_search_results = [{"record": [{"record_id": "one", "body": "hello"}], "score": 0.9}] + results = await collection.search(vector=[1.0, 0.0]) + with pytest.raises(IntegrationInvalidResponseException, match="exactly one record"): + _ = [result async for result in results] + + collection.raw_search_results = [object()] + results = await collection.search(vector=[1.0, 0.0]) + with pytest.raises(IntegrationInvalidResponseException, match="result conversion failed"): + _ = [result async for result in results] + + async def failing_results() -> AsyncIterable[Any]: + yield {"record": {"record_id": "one", "body": "hello"}, "score": 0.9} + raise RuntimeError("stream disconnected") + + collection.raw_search_results = failing_results() + results = await collection.search(vector=[1.0, 0.0]) + with pytest.raises(IntegrationException, match="iteration failed.*stream disconnected"): + _ = [result async for result in results] + + +async def test_scoreless_results_remain_when_threshold_cannot_be_applied() -> None: + collection = MockCollection() + collection.raw_search_results = [{"record": {"record_id": "one", "body": "hello"}, "score": None}] + + results = await collection.search(vector=[1.0, 0.0], score_threshold=0.5) + + responses = [result async for result in results] + assert len(responses) == 1 + assert responses[0]["score"] is None + + +def test_filter_parser_and_default_mapper_edge_paths() -> None: + collection = MockCollection() + assert collection._build_filter(lambda record: record.id == "one") == "record.id == 'one'" + with pytest.raises(ValueError, match="Unable to parse"): + collection._build_filter("lambda record:") + + +async def test_search_tool_filter_mapper_edge_paths() -> None: + collection = MockCollection() + parameters = { + "type": "object", + "properties": { + "query": {"type": "string"}, + "category": {"type": "string"}, + }, + "required": ["query", "category"], + } + tool = create_vector_search_tool( + collection, + parameters=parameters, + filter=["lambda record: record.id != 'ignored'"], + ) + await tool(query="query", category="travel") + assert collection.last_search_filter == ["record.id != 'ignored'", "record.category == 'travel'"] + + invalid_tool = create_vector_search_tool( + collection, + parameters={ + "type": "object", + "properties": {"query": {"type": "string"}, "bad-name": {"type": "string"}}, + "required": ["query"], + }, + ) + with pytest.raises(ValueError, match="cannot be mapped"): + await invalid_tool(query="query", **{"bad-name": "value"}) + with pytest.raises(TypeError, match="'query'.*string"): + await cast(Any, create_vector_search_tool(collection))(query=1) + + +async def test_runtime_operations_mark_vector_store_feature_usage() -> None: + collection = MockCollection() + store = MockStore(collection) + + with patch("agent_framework._vectors.mark_feature_used") as mark_feature_used_mock: + await collection.serialize(Record("one", "hello"), generate_vectors=False) + mark_feature_used_mock.assert_called_with(FeatureIndex.CORE_VECTOR_STORES) + + mark_feature_used_mock.reset_mock() + collection.deserialize({"record_id": "one", "body": "hello", "vector": None}) + mark_feature_used_mock.assert_called_once_with(FeatureIndex.CORE_VECTOR_STORES) + + mark_feature_used_mock.reset_mock() + await collection.upsert([Record("one", "hello")], generate_vectors=False) + mark_feature_used_mock.assert_any_call(FeatureIndex.CORE_VECTOR_STORES) + + mark_feature_used_mock.reset_mock() + await collection.get(["one"]) + mark_feature_used_mock.assert_any_call(FeatureIndex.CORE_VECTOR_STORES) + + mark_feature_used_mock.reset_mock() + await collection.delete(["one"]) + mark_feature_used_mock.assert_called_once_with(FeatureIndex.CORE_VECTOR_STORES) + + mark_feature_used_mock.reset_mock() + await store.collection_exists("records") + mark_feature_used_mock.assert_called_once_with(FeatureIndex.CORE_VECTOR_STORES) + + mark_feature_used_mock.reset_mock() + await collection.search(vector=[1.0, 0.0]) + mark_feature_used_mock.assert_called_once_with(FeatureIndex.CORE_VECTOR_STORES) diff --git a/python/samples/02-agents/vector_stores/README.md b/python/samples/02-agents/vector_stores/README.md new file mode 100644 index 00000000000..92227f0b1f0 --- /dev/null +++ b/python/samples/02-agents/vector_stores/README.md @@ -0,0 +1,56 @@ +# Vector stores + +Vector stores accept multiple model styles so applications can keep the data +representation that already fits their validation, memory, and interoperability +needs. When you own a model, annotate a dataclass, Pydantic model, msgspec +struct, or plain class. When another team or package owns it, register an +explicit definition and codecs. Dictionaries use a collection-specific +definition; DataFrames and other containers can convert to row dictionaries +before calling the batch API. + +No database or credentials are needed for these examples. + +| File | Demonstrates | +|------|--------------| +| [`vector_store_models.py`](vector_store_models.py) | Choosing among owned models, third-party model registration, and loose dictionary definitions. | +| [`optimized_data_formats.py`](optimized_data_formats.py) | Keeping NumPy vector fields and adapting pandas DataFrames to the batch record API. | + +The first section shows the two equivalent custom-codec registration forms. +`@vectorstoremodel` derives the definition from annotations and registers it; +`register_vectorstoremodel` accepts an externally constructed definition. +Both produce the same internal model registration. + +The sample order is informed by a small benchmark on Apple Silicon with +CPython 3.13. Each benchmark model had the same `id`, `text`, and `vector` +fields. Results are medians of seven warmed runs: + +| Model style | 3-element vector | 1,566-element vector | +|-------------|-----------------:|---------------------:| +| Custom codecs | 2.60 μs | 7.01 μs | +| Dictionary | 2.39 μs | 6.60 μs | +| Plain class | 3.46 μs | 12.26 μs | +| msgspec `Struct` | 3.03 μs | 16.61 μs | +| Dataclass | 3.17 μs | 16.81 μs | +| Pydantic | 5.52 μs | 37.32 μs | + +These results measure only the framework's internal record conversion path. +They do not include database SDK conversion, network I/O, +embedding generation, validation complexity, nested fields, alternate vector +representations, or memory allocation. Custom codecs are especially favorable +here because the benchmark codec returns the existing vector reference rather +than copying it. The middle ordering also changes with vector size, so treat +these timings as illustrative data, not a recommendation or performance +guarantee. + +Array-like vector values, including NumPy arrays, are serialized through their +`tolist()` method without making NumPy a core dependency. If a model must +restore a NumPy array instead of a Python list, pass a custom `decoder` to +`@vectorstoremodel` or `register_vectorstoremodel` and call `numpy.array` or +`numpy.asarray` there. + +Run the sample from the `python` directory: + +```bash +uv run samples/02-agents/vector_stores/vector_store_models.py +uv run samples/02-agents/vector_stores/optimized_data_formats.py +``` diff --git a/python/samples/02-agents/vector_stores/optimized_data_formats.py b/python/samples/02-agents/vector_stores/optimized_data_formats.py new file mode 100644 index 00000000000..a80693d5e5b --- /dev/null +++ b/python/samples/02-agents/vector_stores/optimized_data_formats.py @@ -0,0 +1,121 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-core", +# "numpy>=2,<3", +# "pandas>=2,<4", +# ] +# +# [tool.uv.sources] +# agent-framework-core = { path = "../../../packages/core" } +# /// + +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +# Run with: uv run samples/02-agents/vector_stores/optimized_data_formats.py +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Annotated, Any, cast + +import numpy as np +import pandas as pd # pyright: ignore[reportMissingImports] +from agent_framework import ( + VectorStoreCollectionDefinition, + VectorStoreField, + vectorstoremodel, +) +from numpy.typing import NDArray + +"""This sample demonstrates optimized vector data formats. + +When optimized formats already fit the application, Agent Framework should not +force conversion at the model boundary. NumPy arrays reduce the in-memory +footprint of large vectors, while pandas DataFrames can preserve an existing +tabular data pipeline. + +NumPy vectors are encoded through ``tolist()`` without making NumPy a core +dependency. A model decoder restores the array after retrieval. DataFrames are +converted to ordinary row dictionaries before using the vector store batch API +and reconstructed after retrieval. Agent Framework does not need +container-specific behavior or a pandas dependency. + +These formats are choices, not requirements. A plain class or other application +model may be simpler and entirely appropriate. For all standard model and +third-party registration options, see +[vector_store_models.py](vector_store_models.py). +""" + +DIMENSIONS = 1566 + + +# 1. Use a NumPy array as a vector field. +def decode_numpy_record(record: Mapping[str, Any]) -> NumpyRecord: + """Restore a NumPy vector after storage returned an ordinary list.""" + return NumpyRecord( + record_id=cast(str, record["record_id"]), + vector=np.asarray(record["vector"], dtype=np.float32), + ) + + +@vectorstoremodel(collection_name="numpy-records", decoder=decode_numpy_record) +@dataclass +class NumpyRecord: + record_id: Annotated[str, VectorStoreField("key")] + vector: Annotated[ + NDArray[np.float32], + VectorStoreField("vector", dimensions=DIMENSIONS, type_="float"), + ] + + +# 2. Convert a pandas DataFrame to and from ordinary row dictionaries. +dataframe_definition = VectorStoreCollectionDefinition( + [ + VectorStoreField("key", name="id"), + VectorStoreField("data", name="text", is_full_text_indexed=True), + VectorStoreField("vector", name="vector", dimensions=3), + ], + collection_name="dataframe-records", +) + + +def main() -> None: + """Convert NumPy and DataFrame values at the vector store boundary.""" + numpy_vector = np.arange(DIMENSIONS, dtype=np.float32) / np.float32(DIMENSIONS) + serialized_numpy = {"record_id": "numpy-1", "vector": numpy_vector.tolist()} + restored_numpy = decode_numpy_record(serialized_numpy) + + print(f"Serialized vector type: {type(serialized_numpy['vector']).__name__}") + print(f"Restored vector type: {type(restored_numpy.vector).__name__} ({restored_numpy.vector.dtype})") + + frame = pd.DataFrame({ + "id": ["one", "two"], + "text": ["First record", "Second record"], + "vector": [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]], + }) + dataframe_rows = cast(list[dict[str, Any]], frame.to_dict(orient="records")) + restored_frame = pd.DataFrame.from_records(dataframe_rows) + + print(f"Rows passed to batch upsert: {dataframe_rows}") + print("Restored DataFrame:") + print(restored_frame) + + +if __name__ == "__main__": + main() + + +""" +Sample output: +Serialized vector type: list +Restored vector type: ndarray (float32) +Rows passed to batch upsert: [ + {'id': 'one', 'text': 'First record', 'vector': [0.1, 0.2, 0.3]}, + {'id': 'two', 'text': 'Second record', 'vector': [0.4, 0.5, 0.6]} +] +Restored DataFrame: + id text vector +0 one First record [0.1, 0.2, 0.3] +1 two Second record [0.4, 0.5, 0.6] +""" diff --git a/python/samples/02-agents/vector_stores/vector_store_models.py b/python/samples/02-agents/vector_stores/vector_store_models.py new file mode 100644 index 00000000000..d0d79575198 --- /dev/null +++ b/python/samples/02-agents/vector_stores/vector_store_models.py @@ -0,0 +1,174 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +# Run with: uv run samples/02-agents/vector_stores/vector_store_models.py +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Annotated, Any, cast + +import msgspec +from agent_framework import ( + VectorStoreCollectionDefinition, + VectorStoreField, + register_vectorstoremodel, + vectorstoremodel, +) +from pydantic import BaseModel + +"""This sample demonstrates the choices for defining vector store models. + +When you own the model, use the representation that best fits the rest of your +application: a dataclass, Pydantic model, msgspec struct, plain class, or +dictionary. ``@vectorstoremodel`` adds vector store metadata without requiring +the application to adopt one specific modeling library. + +When another package or team owns the model, adapt it instead of rewriting it. +Use ``register_vectorstoremodel`` with an explicit definition and codecs for a +model type, or pass a loose collection definition directly for dictionaries +and other schema-less records. + +For NumPy vectors and DataFrame row containers, see +[optimized_data_formats.py](optimized_data_formats.py). + +The examples are ordered using indicative serialization measurements, but the +right choice depends on validation, memory, interoperability, and the broader +application, not only handler round-trip speed. +""" + + +# 1. Custom codecs can be registered in two equivalent ways. +# The decorator creates the field definition from annotations and registers it with the codecs. +def encode_legacy_faq(faq: LegacyFaq) -> Mapping[str, Any]: + """Convert a legacy FAQ to logical vector model fields.""" + return {"id": faq.faq_number, "question": faq.prompt} + + +def decode_legacy_faq(record: Mapping[str, Any]) -> LegacyFaq: + """Restore a legacy FAQ from logical vector model fields.""" + return LegacyFaq(faq_number=cast(str, record["faq_number"]), prompt=cast(str, record["prompt"])) + + +@vectorstoremodel( + collection_name="legacy-faqs", + encoder=encode_legacy_faq, + decoder=decode_legacy_faq, +) +@dataclass +class LegacyFaq: + faq_number: Annotated[str, VectorStoreField("key", storage_name="id")] + prompt: Annotated[str, VectorStoreField("data", storage_name="question")] + + +# The helper performs the same registration when the definition is supplied separately. +@dataclass +class LegacyArticle: + article_id: int + heading: str + + +def encode_legacy_article(article: LegacyArticle) -> Mapping[str, Any]: + """Convert a legacy article to logical vector model fields.""" + return {"id": str(article.article_id), "title": article.heading} + + +def decode_legacy_article(record: Mapping[str, Any]) -> LegacyArticle: + """Restore a legacy article from logical vector model fields.""" + return LegacyArticle(article_id=int(record["id"]), heading=cast(str, record["title"])) + + +legacy_definition = VectorStoreCollectionDefinition( + [ + VectorStoreField("key", name="id", storage_name="article_id"), + VectorStoreField("data", name="title", storage_name="heading"), + ], + collection_name="legacy-articles", +) +register_vectorstoremodel( + LegacyArticle, + definition=legacy_definition, + encoder=encode_legacy_article, + decoder=decode_legacy_article, +) + + +# 2. Plain dictionaries can be used as models; in that case, we just need the collection-specific definition. +dictionary_definition = VectorStoreCollectionDefinition( + [ + VectorStoreField("key", name="id"), + VectorStoreField("data", name="text"), + VectorStoreField("vector", name="vector", dimensions=3), + ], + collection_name="dictionary-records", +) + + +# 3. Plain classes use their annotated constructor parameters. +@vectorstoremodel(collection_name="notes") +class Note: + def __init__( + self, + note_id: Annotated[str, VectorStoreField("key")], + text: Annotated[str, VectorStoreField("data")], + ) -> None: + self.note_id = note_id + self.text = text + + +# 4. msgspec structs use the default registered codec. +@vectorstoremodel(collection_name="documents") +class Document(msgspec.Struct): + document_id: Annotated[str, VectorStoreField("key")] + title: Annotated[str, VectorStoreField("data")] + vector: Annotated[list[float] | None, VectorStoreField("vector", dimensions=3)] = None + + +# 5. Dataclasses use the default registered codec. +@vectorstoremodel(collection_name="hotels") +@dataclass +class Hotel: + hotel_id: Annotated[str, VectorStoreField("key")] + name: Annotated[str, VectorStoreField("data", is_indexed=True)] + description: Annotated[ + str | list[float] | None, + VectorStoreField("vector", dimensions=3, distance_function="cosine_similarity"), + ] = None + + +# 6. Pydantic models provide validation with additional round-trip cost. +@vectorstoremodel(collection_name="products") +class Product(BaseModel): + product_id: Annotated[str, VectorStoreField("key")] + name: Annotated[str, VectorStoreField("data", is_full_text_indexed=True)] + vector: Annotated[list[float] | None, VectorStoreField("vector", dimensions=3)] = None + + +def main() -> None: + """Inspect model definitions and registration choices.""" + model_definitions = ( + ("LegacyFaq", cast(VectorStoreCollectionDefinition, vars(LegacyFaq)["__vectorstoremodel_definition__"])), + ("LegacyArticle", legacy_definition), + ("dict", dictionary_definition), + ("Note", cast(VectorStoreCollectionDefinition, vars(Note)["__vectorstoremodel_definition__"])), + ("Document", cast(VectorStoreCollectionDefinition, vars(Document)["__vectorstoremodel_definition__"])), + ("Hotel", cast(VectorStoreCollectionDefinition, vars(Hotel)["__vectorstoremodel_definition__"])), + ("Product", cast(VectorStoreCollectionDefinition, vars(Product)["__vectorstoremodel_definition__"])), + ) + for model_name, definition in model_definitions: + print(f"{model_name}: collection={definition.collection_name}, fields={definition.names}") + + +if __name__ == "__main__": + main() + + +""" +Sample output: +LegacyFaq: collection=legacy-faqs, fields=['faq_number', 'prompt'] +LegacyArticle: collection=legacy-articles, fields=['id', 'title'] +dict: collection=dictionary-records, fields=['id', 'text', 'vector'] +Note: collection=notes, fields=['note_id', 'text'] +Document: collection=documents, fields=['document_id', 'title', 'vector'] +Hotel: collection=hotels, fields=['hotel_id', 'name', 'description'] +Product: collection=products, fields=['product_id', 'name', 'vector'] +""" diff --git a/python/samples/AGENTS.md b/python/samples/AGENTS.md index 7250e424f62..739329a1250 100644 --- a/python/samples/AGENTS.md +++ b/python/samples/AGENTS.md @@ -10,7 +10,8 @@ python/samples/ ├── 01-get-started/ # Progressive tutorial (steps 01–07) ├── 02-agents/ # Deep-dive concept samples │ ├── tools/ # Tool patterns (function, approval, schema, etc.) -│ ├── middleware/ # One file per middleware concept +│ ├── vector_stores/ # Vector model schemas and registration +│ ├── middleware/ # One file per middleware concept │ ├── conversations/ # Thread, storage, suspend/resume │ ├── providers/ # One sub-folder per provider (azure_ai/, openai/, etc.) │ ├── context_providers/ # Memory & context injection From ae79e3cb38a0dc3bf4447602ce44d08a137af6f2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:37:36 +0000 Subject: [PATCH 19/85] Python: Chore(deps-dev): Bump zuban from 0.9.1 to 0.9.2 in /python (#8037) * Chore(deps-dev): Bump zuban from 0.9.1 to 0.9.2 in /python Bumps [zuban](https://github.com/zubanls/zubanls-python) from 0.9.1 to 0.9.2. - [Release notes](https://github.com/zubanls/zubanls-python/releases) - [Commits](https://github.com/zubanls/zubanls-python/compare/v0.9.1...v0.9.2) --- updated-dependencies: - dependency-name: zuban dependency-version: 0.9.2 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * Fix zuban lockfile resolution Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> --- python/pyproject.toml | 2 +- python/uv.lock | 46 ++++++++++++++++++++++++++++--------------- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index ec35689bb4b..7856ff7043a 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -41,7 +41,7 @@ dev = [ "pyright==1.1.411", "pyrefly==1.2.0", "ty==0.0.72", - "zuban==0.9.1", + "zuban==0.9.2", "opentelemetry-sdk", #tasks "poethepoet==0.48.0", diff --git a/python/uv.lock b/python/uv.lock index d07affcd7b9..b331b021348 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -181,7 +181,7 @@ dev = [ { name = "tomli", specifier = "==2.4.1" }, { name = "ty", specifier = "==0.0.72" }, { name = "uv", specifier = "==0.12.6" }, - { name = "zuban", specifier = "==0.9.1" }, + { name = "zuban", specifier = "==0.9.2" }, ] test = [ { name = "agent-hooks-sdk", specifier = ">=0.1.0a4,<0.2" }, @@ -3031,7 +3031,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/16/71eefcf68267bbf06a9b6bff57d0b222e49432326e85d74348b67694b8d4/greenlet-3.5.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e883de250e299654b1f1680f72a1a9f9ba62c9bd1bce84099c90657349a8dfbb", size = 294266, upload-time = "2026-07-22T11:37:56.142Z" }, { url = "https://files.pythonhosted.org/packages/36/ea/a0b19adfc35d07e10acb626e9d22a3893b95f1309c42c4a20161dec16800/greenlet-3.5.4-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32802705c2c1ff25e8237b3bdacf2594fa02be80af8a66703eb7853ea7e68686", size = 613712, upload-time = "2026-07-22T12:26:39.375Z" }, { url = "https://files.pythonhosted.org/packages/54/76/a121978b3337407d05a1ce5f79b4aa5998a43a9d8422f9726029b90b4471/greenlet-3.5.4-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57aa201b351f7c7c75627c60d29e4d5b97a07d37efeb62b903466fca42c097d7", size = 625582, upload-time = "2026-07-22T12:29:00.814Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4a/f301f1d85c69a86b90b5d581a73e8927bba4e79450037e6e2cbca05eb4fd/greenlet-3.5.4-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9667862a2e38ad379f11b845daeda22c8989186def44f06962c9c4c05e556da7", size = 633429, upload-time = "2026-07-22T12:43:42.073Z" }, { url = "https://files.pythonhosted.org/packages/34/c2/080f16cf870e929e592f55767f01d6c98d2ee83bfdc36c3b892f2d0459ab/greenlet-3.5.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3fe76c2cac86b4f7a1e92865ac0a54384deb05c92986287c1a7110d9bd53071", size = 624663, upload-time = "2026-07-22T11:51:08.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2e/26884072b0eb343a4d5fee903341bfe5171b32b7f14553886e2b6349135a/greenlet-3.5.4-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:ae53534b5dec0f4c2ec26f898f538dc8ea1ca3ef2927d597a9439e40a09da937", size = 428238, upload-time = "2026-07-22T12:39:49.973Z" }, { url = "https://files.pythonhosted.org/packages/9e/bb/8f3ca88370b817369008faeceeee85970adc16c92a70a3e5fe5fea495a57/greenlet-3.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1e1a4a684b16c45ba324e60b32a4386a87722bcb815d2a149d2182f9b401ca72", size = 1585010, upload-time = "2026-07-22T12:25:02.539Z" }, { url = "https://files.pythonhosted.org/packages/51/c2/45877154689709ebce9a0b83c2235e6ca0f31577889b02af308c8cc5f8fb/greenlet-3.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e849e6e139b9671adeac505f72fc05f4af7fd1921faef40295e214fc3b361b59", size = 1651283, upload-time = "2026-07-22T11:51:10.408Z" }, { url = "https://files.pythonhosted.org/packages/cd/7d/8711a75cb61d85246277c07ff6e1a6504621ba473d808c11ad225ffca43f/greenlet-3.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:dc418cf4c873357964d6624445ed09472e50def990c65dd4e76fc3ba8cd9cef6", size = 246434, upload-time = "2026-07-22T11:43:15.557Z" }, @@ -3039,7 +3041,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/04/81bd731d6d1e3a469d9a4c36f5eb069bcf0cbb2d5d342c9fec22245b91fc/greenlet-3.5.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3d66250e8b09f182ede05490998c818b5961f7a3640332d44c4927caec7bbfe4", size = 295909, upload-time = "2026-07-22T11:38:09.261Z" }, { url = "https://files.pythonhosted.org/packages/cc/dd/f5f22903a6ae70f5ea328ed0beaec92ad903f0e3b7d2845133b354abc4b8/greenlet-3.5.4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90e930c9c192e5b3ee9fb8bcd920ea3926155e2e3ded39fc697323addecee17", size = 612011, upload-time = "2026-07-22T12:26:40.69Z" }, { url = "https://files.pythonhosted.org/packages/8e/10/92a4a88d12b915d74ea5b6d288e4afefda4771647caa34442c156f7a454f/greenlet-3.5.4-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:791fdfeeb9c6e0c7b10fa151bf110d2a6974866f13dcb5b1c7efae698245893a", size = 624299, upload-time = "2026-07-22T12:29:02.089Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f9/03e26be3487c5238e81f2b84714959a86ea8515a869828cf41f4fc54b34e/greenlet-3.5.4-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b7c895310363f310361e0fe2072af85269d2a2a285cd04c0c59e79a5e3670dcf", size = 629603, upload-time = "2026-07-22T12:43:43.456Z" }, { url = "https://files.pythonhosted.org/packages/50/6d/0b14bb9db2989f32cd9fe7f76afedea01ee8bee3f87c07e69f24adfe7e63/greenlet-3.5.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f88193799d43dbf8c8a806d6405c9c52fe2af40bf75072a606357b33cc336c7f", size = 621541, upload-time = "2026-07-22T11:51:09.464Z" }, + { url = "https://files.pythonhosted.org/packages/57/6b/7c55ca72ef80d57c16c4a55210f82582622462dc4485799a30f4ec6f3372/greenlet-3.5.4-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:13b980043cb1b3134e81ea469da1250ddcc6bfe6d245bbaa59168d9cdc8f228f", size = 432554, upload-time = "2026-07-22T12:39:51.379Z" }, { url = "https://files.pythonhosted.org/packages/48/3d/25e9a2d9eb6b2e8b7ca4e80a3a26cb887cce6c8e0a87c921164f11bc5574/greenlet-3.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7a5f095767c4493afcd06067f2bb3b8716e3f3f9e92b99c88e7e99f885b3d4d", size = 1581444, upload-time = "2026-07-22T12:25:03.818Z" }, { url = "https://files.pythonhosted.org/packages/b9/96/4c9bf2e2c408dcc0556edce69efa9f802e82223573c53240136a086821f1/greenlet-3.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:42afdc1ab5f66da8c586c32af9224a74a706b4f0ea0dc3a4188a0860a09c65c9", size = 1645842, upload-time = "2026-07-22T11:51:12.295Z" }, { url = "https://files.pythonhosted.org/packages/b5/41/303ecb26a3a56122c0f4d4073ee078881847bd6b6f463ae0ec57ec20223b/greenlet-3.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:60149df8f462d1b230038e6590c23c3b4768bb5d6c022b3b6e82532b34b0b8a3", size = 247169, upload-time = "2026-07-22T11:38:19.893Z" }, @@ -3047,7 +3051,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/9a/e51225dcd58713f16ccbdcc501a8da21098ea14515b7870f1f94459e5ff5/greenlet-3.5.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02", size = 294831, upload-time = "2026-07-22T11:38:53.389Z" }, { url = "https://files.pythonhosted.org/packages/9f/ea/de50a50fadf979713ab18b46f22ad5ff5f2dcfc637a3ebdecf669801e1a5/greenlet-3.5.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356", size = 614619, upload-time = "2026-07-22T12:26:42.282Z" }, { url = "https://files.pythonhosted.org/packages/db/c7/2aae27fea41205b8650294c301f042a2a4bb6155eea48c995b890a92f2c1/greenlet-3.5.4-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef", size = 627021, upload-time = "2026-07-22T12:29:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/1b/80/fb4d4788bbc8e54761f1fc88533af9523a6e86299fa113d6e8a8503ed9fc/greenlet-3.5.4-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07bd44616608d873d06735b63ef1a88191d6ca57c8d291d6559c71bc14c0893c", size = 632845, upload-time = "2026-07-22T12:43:45.19Z" }, { url = "https://files.pythonhosted.org/packages/eb/56/79fd826f9ccaae0b84e1b4ef68dabba5e105bb044ffcd448a0b782fcba9a/greenlet-3.5.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0", size = 624002, upload-time = "2026-07-22T11:51:11.391Z" }, + { url = "https://files.pythonhosted.org/packages/42/e3/6086fa578ebb72772722cdc4bcd628459814b42e0c2db1e3cbd6552b3271/greenlet-3.5.4-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:3529a8a933582ad19e224792cac7372489526576b75b4c124e8e4f29948f4861", size = 435053, upload-time = "2026-07-22T12:39:52.715Z" }, { url = "https://files.pythonhosted.org/packages/0a/1a/27319f97e731298513dcba1a2e91b63e9d8811d9de22130f960b129b1bf1/greenlet-3.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd", size = 1581533, upload-time = "2026-07-22T12:25:05.322Z" }, { url = "https://files.pythonhosted.org/packages/b1/6d/24240bf562e9786dd2799ee0a4a4dadb4ded22510f41b20245099159ac8c/greenlet-3.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f", size = 1645781, upload-time = "2026-07-22T11:51:14.805Z" }, { url = "https://files.pythonhosted.org/packages/c1/5a/442ab1a9ef7ca6bf7210e5397a95972206a91a31033a03c8900866a10039/greenlet-3.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c", size = 247133, upload-time = "2026-07-22T11:39:20.661Z" }, @@ -3055,7 +3061,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a5/a7/6ab1d4f9cd548d15ab90da29947f2076100130bb179b0bde59f795a459e3/greenlet-3.5.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22", size = 295410, upload-time = "2026-07-22T11:40:35.747Z" }, { url = "https://files.pythonhosted.org/packages/cd/7a/422f63b4715cbc0b24385305407adf38b48f6bb68b3e6b04090e994d0f5a/greenlet-3.5.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf", size = 661286, upload-time = "2026-07-22T12:26:43.8Z" }, { url = "https://files.pythonhosted.org/packages/d0/31/5a1cac663bf5582190c5a714ef81364f03cde232227f39748f8ae4c11da5/greenlet-3.5.4-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9", size = 673517, upload-time = "2026-07-22T12:29:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/9c/bf/250c2921c7b585dde12f5239e313ca2dcbc464d161ecca36e4e6ef21762d/greenlet-3.5.4-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cef589bc65fae02d10bca2ac341191c5b33acc2967892ebf4fcbd10eabb7a74c", size = 677968, upload-time = "2026-07-22T12:43:46.788Z" }, { url = "https://files.pythonhosted.org/packages/15/4a/2a82a1e3f8aaca020853ac8d12211280ca2b231aa08ea39f636f1060c319/greenlet-3.5.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8", size = 670917, upload-time = "2026-07-22T11:51:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/18/40/10bfcf6513558d82f7b95dd728001c63bd388259fe27d3e30ae01f103430/greenlet-3.5.4-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:dfc41ae893d9ceaf22c824f2153a88b30651b20e8758c2cd9ac143f23640563c", size = 480643, upload-time = "2026-07-22T12:39:54.149Z" }, { url = "https://files.pythonhosted.org/packages/68/b0/e379a152b17bfdfa95795af4049e37c0fd1b4d81f020d426db104ed07c77/greenlet-3.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3", size = 1628478, upload-time = "2026-07-22T12:25:06.678Z" }, { url = "https://files.pythonhosted.org/packages/5e/43/bffdfa64f7317f954c5c1230b5dd5922676ce198689a68c1ac1ed4b1b1a5/greenlet-3.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec", size = 1692021, upload-time = "2026-07-22T11:51:17.008Z" }, { url = "https://files.pythonhosted.org/packages/d0/11/f799f9637e2c6e9b0b716015e339040598b058cf7654dfc0d67468b177ed/greenlet-3.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c", size = 248031, upload-time = "2026-07-22T11:40:11.007Z" }, @@ -3063,14 +3071,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/69/35c62ed49c320cb4d98e14698ccca5467d3bfe683984172be9cb564d9ce3/greenlet-3.5.4-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3", size = 305571, upload-time = "2026-07-22T11:40:31.659Z" }, { url = "https://files.pythonhosted.org/packages/5b/6c/64d60216b3640dcb0b62d913dd9e0d80030c09115bb2e4ba70c95d10ca45/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867", size = 672568, upload-time = "2026-07-22T12:26:45.298Z" }, { url = "https://files.pythonhosted.org/packages/5c/de/ba3ab0a96292e53039530333b0d2ae18d9e508f3a325cd7bf15f8172944c/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c", size = 680076, upload-time = "2026-07-22T12:29:06.125Z" }, + { url = "https://files.pythonhosted.org/packages/ae/db/24a10af12bf8e639cec46c38b9ce1a282543ba42ff4fb0b31a970f1ab603/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39169a11d87a6a263afda3e9a27d1df16d0f919d40a4837cc73986c9884c0dd8", size = 681690, upload-time = "2026-07-22T12:43:48.109Z" }, { url = "https://files.pythonhosted.org/packages/a5/be/aeada79083c6f1c15f45d77a332f9c441af263ee298e3eb17522cd337d22/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66", size = 676733, upload-time = "2026-07-22T11:51:16.027Z" }, + { url = "https://files.pythonhosted.org/packages/f4/60/44a2eca7b9fd71ae0fae7ff184da1cd3169d176652b97aa1cffcbb0ef961/greenlet-3.5.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:bd3d1145f603b2db19feb9078c2e6855eb7c67e15580c010ed815cee519b86fd", size = 510263, upload-time = "2026-07-22T12:39:55.678Z" }, { url = "https://files.pythonhosted.org/packages/e9/10/2392fc3a98948652ef5fd1e7275c04f861dd13f74b78a2b4309f4ee4d090/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7", size = 1637327, upload-time = "2026-07-22T12:25:07.879Z" }, { url = "https://files.pythonhosted.org/packages/55/c6/e7237a3dfa1f205ed0d9ea1e46d70bd2811b32d516266399fb59d28ab90a/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e", size = 1697493, upload-time = "2026-07-22T11:51:19.214Z" }, { url = "https://files.pythonhosted.org/packages/55/e3/4ba8154ba2a3d43729e499f72471b4b5c993f3826d3e24da81d5f06d6572/greenlet-3.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132", size = 251637, upload-time = "2026-07-22T11:40:37.44Z" }, { url = "https://files.pythonhosted.org/packages/90/03/e3f96dfc100261a29545ddc8270cafe58f9195b6651466910e820910de77/greenlet-3.5.4-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809", size = 296076, upload-time = "2026-07-22T11:39:38.364Z" }, { url = "https://files.pythonhosted.org/packages/a4/3d/da52d208e5c977bce8667e784729e584e38b5785f4c1ec0f4c836e9a1c42/greenlet-3.5.4-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927", size = 666870, upload-time = "2026-07-22T12:26:46.691Z" }, { url = "https://files.pythonhosted.org/packages/2d/8a/7e6dee25cb8a8cf9b362c8e597cc269593378bd916f16c736c059a52e85a/greenlet-3.5.4-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5", size = 677678, upload-time = "2026-07-22T12:29:07.508Z" }, + { url = "https://files.pythonhosted.org/packages/51/a7/dafc7415d430b0a43a16396eb49ecb3b62fd720877fb259cc4dcfaf5f31e/greenlet-3.5.4-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1f17e362d78e37559e0506c5a7d066bdd45073c36a0127a543e8a0df27242ff3", size = 681428, upload-time = "2026-07-22T12:43:49.623Z" }, { url = "https://files.pythonhosted.org/packages/6c/21/5a38699fa45de749e3857d93b8f07e4c20489e77c2d35d915a2e1c456606/greenlet-3.5.4-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:394de08dad5ffcb1f50c2159d93e398d9d2da3ed437645eaa54771fa720db9f0", size = 676067, upload-time = "2026-07-22T11:51:18.163Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d9/6298f3432de301d4718766cf934bd73c418c73f81fbb77247319364b0d96/greenlet-3.5.4-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:cd320d998cbaa032932830448e39abf3c6a12901295e386e8114db926e10cffb", size = 487446, upload-time = "2026-07-22T12:39:57.044Z" }, { url = "https://files.pythonhosted.org/packages/5e/ba/863116ab8ff1ca7a729e327800268939d182db47aa433db70e216e7d9194/greenlet-3.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88", size = 1633489, upload-time = "2026-07-22T12:25:09.605Z" }, { url = "https://files.pythonhosted.org/packages/fa/06/7466ced82818d6132462d7f26b3f83c66ea15d2b193a6c0088d558ed7d95/greenlet-3.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c", size = 1696584, upload-time = "2026-07-22T11:51:21.304Z" }, { url = "https://files.pythonhosted.org/packages/f9/4d/55b638489260065de9ffce606c8b5d04507bef705de4b212a0c3d6a1a0df/greenlet-3.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da", size = 248297, upload-time = "2026-07-22T11:42:04.055Z" }, @@ -3078,7 +3090,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/66/7c87ed9cdbf1d49c2c6cd1c7b9dd4d16c33b24235ca03972293a1876b30c/greenlet-3.5.4-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c", size = 306487, upload-time = "2026-07-22T11:41:25.118Z" }, { url = "https://files.pythonhosted.org/packages/5b/05/0a4201e7c0054866eefc05da234f236dd4c950d0fbf9ca0517141f01b269/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9", size = 676479, upload-time = "2026-07-22T12:26:48.129Z" }, { url = "https://files.pythonhosted.org/packages/3d/c0/4b6b8c5a3aec70f0649cd89662d120fdd6421e2bdc8e3b15c3ab5ec568d8/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25", size = 684321, upload-time = "2026-07-22T12:29:08.925Z" }, + { url = "https://files.pythonhosted.org/packages/88/15/0b167aeea95285b0e654ddce651922f666c089363c2ec528ca8b9a9ba74f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:123aa379c962ed5fe90a880327e0c3066124ac64ec99e12a238be9fd8eb3db3d", size = 685995, upload-time = "2026-07-22T12:43:50.993Z" }, { url = "https://files.pythonhosted.org/packages/24/c9/b49c31c9a972eee91e260445770e922244a7efc542697f51a012ec046d0f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f1467de1bb767f75db0aa34c195e3a496d8d1278c796e70c24ce205d3e99cde", size = 681293, upload-time = "2026-07-22T11:51:20.43Z" }, + { url = "https://files.pythonhosted.org/packages/de/90/c023ec337f32ff505be7db759c80d98f0532bb94d0c6fa13645efe9bee2e/greenlet-3.5.4-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:adf2244d7f69409925a8f22ed22cc5f93cdfe5c9dc87ff3476be2c2aaae61a05", size = 516928, upload-time = "2026-07-22T12:39:58.359Z" }, { url = "https://files.pythonhosted.org/packages/95/6f/7f2d4653770500eee667866016d42d7a68e3d3462f80df6b8e3fcd48a0eb/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8", size = 1642474, upload-time = "2026-07-22T12:25:10.819Z" }, { url = "https://files.pythonhosted.org/packages/5a/d8/8cba31036a4caae448087ba5d150660ab03b4a0f54d9150f6495a3be7262/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2", size = 1701012, upload-time = "2026-07-22T11:51:23.17Z" }, { url = "https://files.pythonhosted.org/packages/e3/cd/3f77a4cce3bae631b08eb52f53a82a976669600337e21dfdba811cb50267/greenlet-3.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2", size = 251977, upload-time = "2026-07-22T11:41:38.125Z" }, @@ -8099,19 +8113,19 @@ wheels = [ [[package]] name = "zuban" -version = "0.9.1" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/cf/7701477eab6244532447ff6132ea2af2c7aec2f1ef82db446d018aee7ad8/zuban-0.9.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:bf9d76d87215ac06433016353c7a751d0bb570f5bd7065ee884003a7333e6d52", size = 11346895, upload-time = "2026-07-31T22:12:46.376Z" }, - { url = "https://files.pythonhosted.org/packages/70/d1/6db81a0e25b59431313d08c8ba7612708fa6134a7ef881f438b777ad25cf/zuban-0.9.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:710eeec6725dc55a268f86b09d9a72e2ca0a9852800d0a58a20124732426eedb", size = 11073232, upload-time = "2026-07-31T22:12:48.968Z" }, - { url = "https://files.pythonhosted.org/packages/1b/36/c7f7bb36d634387c9f7dddd0201760a8755b2e65b80697cf207ee62ff741/zuban-0.9.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d10b28ed050f50b1e0cfe2a6d236d18361376a822dd0d28f9ddd4768733ee5d", size = 28278516, upload-time = "2026-07-31T22:12:51.604Z" }, - { url = "https://files.pythonhosted.org/packages/7e/ff/32a9a8bd4c33a22abd3f7b2a1d2b21643627608ca51a59503268dd390646/zuban-0.9.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ab65c8bd0bb89f4cd5be56ebf8c63223b68f8d125f6883b39c304c2ef245a5cf", size = 28571449, upload-time = "2026-07-31T22:12:54.905Z" }, - { url = "https://files.pythonhosted.org/packages/9d/30/9112460b7c069338b6f1262e4663b744a149ea58520022e867cb19f5c014/zuban-0.9.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7832d97411a005880f7e89588dc8a207cc2e17a3d3b7875aadf7e1553ae1f4a6", size = 29739155, upload-time = "2026-07-31T22:12:57.965Z" }, - { url = "https://files.pythonhosted.org/packages/d0/f9/5ab411dbcfc118934feb19d2a80dba79277d4be2ebf89554e97918c2a826/zuban-0.9.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eabf684202197630b4ba23eb236e037ed2627bbb4acdb34db8c7288e9668ad5d", size = 31614638, upload-time = "2026-07-31T22:13:01.03Z" }, - { url = "https://files.pythonhosted.org/packages/4b/67/bbbb52fc7bbb773bdfa4cf546f9c15136826c9c4a03beddb3686db2c7f7e/zuban-0.9.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:67354f17d0e267c633dc8ae287c6c062d9b8f5f1ca83cc7cca85de7f0d88e686", size = 28455302, upload-time = "2026-07-31T22:13:03.819Z" }, - { url = "https://files.pythonhosted.org/packages/e9/c8/b57ac05d879c54e69947cd270e0da21fd76dda6cece135e528f2c459f9bf/zuban-0.9.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8a828388987d0d0e77160e9ae8e9fd1b6f65d071812b0004f6537c1a9d694a07", size = 28981446, upload-time = "2026-07-31T22:13:07.201Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b1/2f6cfaa2a319e1c5b06c62ee2710c55416cc660a3fff83ab354654d5d721/zuban-0.9.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:25dee4561c8e8c9bb5da98c13e7ea5a1d9bbf2778f7c0f7251599f7051ef52ec", size = 29610910, upload-time = "2026-07-31T22:13:10.209Z" }, - { url = "https://files.pythonhosted.org/packages/ec/79/36b5187c5ac5b80516e6e0cdf240282a8c49e7ee8ae88a426d0b0328963c/zuban-0.9.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:387407431d876c0b9993c14e3aa2b2ccd19e443b1a7dde72a5f7f55e864118f1", size = 28894561, upload-time = "2026-07-31T22:13:12.991Z" }, - { url = "https://files.pythonhosted.org/packages/62/f1/5a27e21f534fb2349fa914aa688d431579e5f49b0841f958618821660a6d/zuban-0.9.1-py3-none-win32.whl", hash = "sha256:ccafab33ae98e0ae9a826010d954f367a4a8c76378c58c5bc75bdedceaa54e70", size = 10044309, upload-time = "2026-07-31T22:13:15.558Z" }, - { url = "https://files.pythonhosted.org/packages/b9/32/ca6d67180dcbc408c0ec6dd55915dd8471982dde4443dcd492d7d52610ec/zuban-0.9.1-py3-none-win_amd64.whl", hash = "sha256:c401b88742e8a501c68f4ec605d7ebc6a63401653c15f0173a76febe161bd53e", size = 10724603, upload-time = "2026-07-31T22:13:18.086Z" }, +version = "0.9.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/c0/1c395a08b2a7c48fbbb1398818d65813032ad0acfec5a841db0e7142a6ac/zuban-0.9.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8922df80834c2d5cb1e2798192dc85d40b4dc3fa3149e7b2b89dccf3cdc110d8", size = 11377755, upload-time = "2026-08-26T00:19:21.022Z" }, + { url = "https://files.pythonhosted.org/packages/af/3c/b323802de4a86f71479b85d388294380205006362bca4414d3213469136a/zuban-0.9.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:112c9bd639ec417fe7881a7d99bc34e35ac6ba6e0afd4f33caf0774ea8ae84fc", size = 11101402, upload-time = "2026-08-26T00:19:24.548Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3b/71ee867aebe9175de1db0208c2eeee7f294821545e6140c28e5ed9f21888/zuban-0.9.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8d65dafe086a8e73cbb8e6d7e3b234a7d2e64cf794d49c5ca0ca901a2880bea", size = 28451467, upload-time = "2026-08-26T00:19:28.898Z" }, + { url = "https://files.pythonhosted.org/packages/bc/93/7331f9f096b1c4a9d575ed6e86572cf6c5ac9eab5dc1c68d0d875e78d549/zuban-0.9.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d8431a65ac7815af24e015d6f6c935518625e015e028b061899677a3d43c6349", size = 28687477, upload-time = "2026-08-26T00:19:33.102Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5a/de09de19a0ea8a4593632631c905751cf380cf8a92d1607632ad40c683ea/zuban-0.9.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d271a27a56ad89e3bb5942580aa903354c3861bfab29e5c655d44007d1d15a2", size = 29736764, upload-time = "2026-08-26T00:19:38.73Z" }, + { url = "https://files.pythonhosted.org/packages/cb/71/2e32dfafa5ac302b7b9cc48bbcfbc9da6bca221ccfd03da5b9d2a28240ee/zuban-0.9.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20a467e9d90f5dce53be3c760d9b65e768870a4e940a5256985f67155587eeb9", size = 31719649, upload-time = "2026-08-26T00:19:43.454Z" }, + { url = "https://files.pythonhosted.org/packages/b2/24/d437b54a088b2a35df948841d011a011ba5db59bdb4bad7c800dffcbbe2e/zuban-0.9.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a2c29e501de2f39690eadffab6369c9ac949bdab82aaab4c9fcbbfda0794d704", size = 28630746, upload-time = "2026-08-26T00:19:47.888Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/a132d0ad526d8986475344546b22acfea0bd6856bb293b70e691fa9bf5d5/zuban-0.9.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:23e34cd5d3c2aad476506fc2a0d3f4fd314e87bb03c873ec2c319497b06f16df", size = 29122179, upload-time = "2026-08-26T00:19:52.937Z" }, + { url = "https://files.pythonhosted.org/packages/70/4e/ebbec923b02c69eee955cc65d227c4a1b6f5c6f2b509b92c9df02ad67e2b/zuban-0.9.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:40f4737d3f7c5926b986ebb4cb44767b2b72788072e833c49d2d3ad2f69ecbdb", size = 29624765, upload-time = "2026-08-26T00:19:57.778Z" }, + { url = "https://files.pythonhosted.org/packages/db/7f/8baa60499029d57bc0dbc254d75469f7b7860654671d0bf3683bc6c56f05/zuban-0.9.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:26a83d3c1f12cd131774a4f89b3c0d750619e7035d02dcd4f668fb43e5ba2fea", size = 28964675, upload-time = "2026-08-26T00:20:02.558Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d8/4a6f29958fa962c2b12ec32a0624708b9da93bec236ace0ac993632d05b1/zuban-0.9.2-py3-none-win32.whl", hash = "sha256:59d09caf2e488eb6d31a3bead1699349f66dafbfd595eb64bcd8e11b79d8e2d1", size = 10051150, upload-time = "2026-08-26T00:20:05.965Z" }, + { url = "https://files.pythonhosted.org/packages/af/c2/123e0f3054f688039290766fdbdd4cba96834537e6fd3f6c95ae460858bd/zuban-0.9.2-py3-none-win_amd64.whl", hash = "sha256:9bc429ef78d3f21a6aeec9cddffe8748235ef2374ef318a91f4aa089cafba75c", size = 10730401, upload-time = "2026-08-26T00:20:08.97Z" }, ] From 1f01b0d4de3135276889743710d1c9bd839fe406 Mon Sep 17 00:00:00 2001 From: Manjunath Janardhan <35326118+manjunathshiva@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:56:23 +0000 Subject: [PATCH 20/85] Python: fix: surface AG-UI workflow intermediate events as reasoning (#8003) * Python: fix: surface AG-UI workflow intermediate events as reasoning The AG-UI workflow runner only handled "output" and "data" workflow events, so "intermediate" events (the modern designation produced by executors listed under intermediate_output_from) fell through to the generic CustomEvent fallback instead of being converted to AG-UI reasoning events. Add "intermediate" to the handled set and route intermediate text content to text_reasoning so consumers render it as a collapsible "thinking" block rather than a final assistant message. The deprecated "data" alias is treated the same as "intermediate" (per its definition as the intermediate compatibility alias in _events.py). "output" keeps the terminal-message behavior, and non-text content (tool calls and results) still emits as its native AG-UI events. Fixes #8000 * Python: address AG-UI intermediate-reasoning review feedback Follow-up to the intermediate-reasoning change on this branch, addressing maintainer review: - Preserve protected_data when re-tagging intermediate text as reasoning so the ReasoningEncryptedValueEvent and the snapshot encryptedValue survive. - Close any open reasoning block (and assistant text message) before every terminal event (RUN_FINISHED / RUN_ERROR) and before a request_info tool call, via an idempotent _drain_open_blocks() helper. Previously the post-loop cleanup emitted the reasoning end events after the terminal event, so a client that stopped at RUN_FINISHED never saw them; a request_info tool call could likewise sit inside an unclosed reasoning block. - Keep role-less AgentResponseUpdate text (and tool content) instead of dropping it to a CUSTOM workflow_output. Explicit non-assistant roles and approval requests stay excluded. --- .../agent_framework_ag_ui/_workflow_run.py | 104 ++++++++-- .../ag-ui/tests/ag_ui/test_workflow_run.py | 190 +++++++++++++++++- 2 files changed, 276 insertions(+), 18 deletions(-) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py b/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py index 866086fea77..f14aa0e206c 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py @@ -910,6 +910,17 @@ def _workflow_payload_to_contents(payload: Any) -> list[Content] | None: if isinstance(payload, AgentResponseUpdate): contents = list(payload.contents or []) role_field = payload.role + if role_field is None: + # ``role`` is optional and streamed continuation chunks routinely omit it. + # Keep their text -- previously dropped, so role-less text surfaced as a + # CUSTOM workflow_output instead of reasoning/assistant text -- alongside tool + # content. Approval requests stay excluded (see _TOOL_CONTENT_TYPES): a + # role-less approval interrupt from streamed content has no pending request to + # resume against. + role_less_contents = [ + content for content in contents if content.type == "text" or content.type in _TOOL_CONTENT_TYPES + ] + return role_less_contents or None if isinstance(role_field, str): role = role_field else: @@ -933,6 +944,30 @@ def _workflow_payload_to_contents(payload: Any) -> list[Content] | None: return None +def _as_reasoning_content(content: Content) -> Content: + """Re-tag plain text content as ``text_reasoning``. + + Intermediate workflow output should surface as AG-UI reasoning (a collapsible + "thinking" block) rather than a final assistant message. Only ``text`` content + is converted; tool calls, results, and other content types pass through + unchanged so they still emit as their native AG-UI events. + """ + if content.type != "text": + return content + return Content.from_text_reasoning( + id=content.id, + text=content.text, + # Carry encrypted reasoning metadata through unchanged: _emit_text_reasoning + # turns protected_data into a ReasoningEncryptedValueEvent and an + # ``encryptedValue`` on the snapshot entry, so dropping it here would break + # reasoning state continuity for intermediate content that carries it. + protected_data=content.protected_data, + annotations=content.annotations, + additional_properties=content.additional_properties or None, + raw_representation=content.raw_representation, + ) + + def _event_name(event: Any) -> str: event_type = getattr(event, "type", None) if isinstance(event_type, str) and event_type: @@ -1115,6 +1150,22 @@ def _drain_open_message() -> list[TextMessageEndEvent]: flow.accumulated_text = "" return [TextMessageEndEvent(message_id=current_message_id)] + def _drain_open_blocks() -> list[BaseEvent]: + """Close any open reasoning block and assistant text message. + + Emitted before content that must not sit inside an open block: a terminal event + (RUN_FINISHED / RUN_ERROR, which must be the final events in the stream) or a + request_info tool call (non-reasoning message content). Otherwise the block's + REASONING_* / TEXT_MESSAGE_* end events would be flushed only by the post-loop + cleanup -- after the terminal event, or after the tool call. Both inner helpers + are no-ops when their block is not open, so this is always safe to call (a later + cleanup pass then simply does nothing). + """ + events: list[BaseEvent] = [] + events.extend(_close_reasoning_block(flow)) + events.extend(_drain_open_message()) + return events + fwd_kwargs: dict[str, Any] = {} if "forwarded_props" in input_data: forwarded_props = input_data["forwarded_props"] @@ -1170,6 +1221,10 @@ def _drain_open_message() -> list[TextMessageEndEvent]: run_started_emitted = True if event_type == "failed": + # Close any open reasoning block / text message so RUN_ERROR stays the + # last event a client receives for this run. + for end_event in _drain_open_blocks(): + yield end_event details = getattr(event, "details", None) yield RunErrorEvent(message=_details_message(details), code=_details_code(details)) run_error_emitted = True @@ -1183,9 +1238,9 @@ def _drain_open_message() -> list[TextMessageEndEvent]: else: state_value = str(getattr(state, "value", state)) if state_value in _TERMINAL_STATES and not terminal_emitted: - # Close any open assistant text message before the terminal event so - # RUN_FINISHED is always the last emitted event. - for end_event in _drain_open_message(): + # Close any open reasoning block and assistant text message before the + # terminal event so RUN_FINISHED is always the last emitted event. + for end_event in _drain_open_blocks(): yield end_event if not interrupts: interrupts.extend(_interrupts_from_pending_requests(await _pending_request_events(workflow))) @@ -1238,7 +1293,10 @@ def _drain_open_message() -> list[TextMessageEndEvent]: continue if event_type == "request_info": - for end_event in _drain_open_message(): + # A request_info emits a tool call (non-reasoning message content), so any + # open reasoning block / text message must be closed first -- otherwise the + # tool call would sit inside an unclosed reasoning block. + for end_event in _drain_open_blocks(): yield end_event request_payload = _request_payload_from_request_event(event) if request_payload is None: @@ -1258,7 +1316,12 @@ def _drain_open_message() -> list[TextMessageEndEvent]: yield CustomEvent(name=_INTERRUPT_CARD_EVENT_NAME, value=interrupt_event_value) continue - if event_type in {"output", "data"}: + if event_type in {"output", "intermediate", "data"}: + # "intermediate" (and its deprecated alias "data") carry non-terminal + # output. Their text is surfaced as AG-UI reasoning so consumers render + # it as a collapsible "thinking" block instead of a final assistant + # message. "output" keeps the terminal-message behavior. + is_intermediate = event_type in {"intermediate", "data"} output_payload = getattr(event, "data", None) if isinstance(output_payload, BaseEvent): yield output_payload @@ -1277,15 +1340,25 @@ def _drain_open_message() -> list[TextMessageEndEvent]: yield out_event contents = _workflow_payload_to_contents(output_payload) if contents: - output_text = _text_from_contents(contents) - skip_text = bool(output_text and output_text == last_assistant_text) - for content in contents: - for out_event in _emit_content(content, flow, predictive_handler=None, skip_text=skip_text): - yield out_event - if flow.message_id and flow.accumulated_text: - last_assistant_text = flow.accumulated_text.strip() or last_assistant_text - elif output_text: - last_assistant_text = output_text + if is_intermediate: + # Reasoning is a separate channel from the final assistant + # message, so the last_assistant_text dedup does not apply. + for content in contents: + reasoning_content = _as_reasoning_content(content) + for out_event in _emit_content( + reasoning_content, flow, predictive_handler=None, skip_text=False + ): + yield out_event + else: + output_text = _text_from_contents(contents) + skip_text = bool(output_text and output_text == last_assistant_text) + for content in contents: + for out_event in _emit_content(content, flow, predictive_handler=None, skip_text=skip_text): + yield out_event + if flow.message_id and flow.accumulated_text: + last_assistant_text = flow.accumulated_text.strip() or last_assistant_text + elif output_text: + last_assistant_text = output_text else: yield CustomEvent(name="workflow_output", value=make_json_safe(output_payload)) continue @@ -1298,6 +1371,9 @@ def _drain_open_message() -> list[TextMessageEndEvent]: if not run_started_emitted: yield RunStartedEvent(run_id=run_id, thread_id=thread_id) run_started_emitted = True + # Close any open reasoning block / text message so RUN_ERROR stays the final event. + for end_event in _drain_open_blocks(): + yield end_event if not run_error_emitted: yield RunErrorEvent(message=str(exc), code=type(exc).__name__) run_error_emitted = True diff --git a/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py b/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py index 7ee5d835c5f..f1cd16c9593 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py +++ b/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py @@ -34,6 +34,7 @@ from pydantic import BaseModel from agent_framework_ag_ui._workflow_run import ( + _as_reasoning_content, _coerce_content, _coerce_json_value, _coerce_message, @@ -120,6 +121,178 @@ async def start(message: Any, ctx: WorkflowContext[Any, str]) -> None: assert custom_events[0].value == {"progress": 10} # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] +async def test_workflow_run_maps_intermediate_text_to_reasoning_events(): + """Intermediate workflow output is surfaced as AG-UI reasoning, not a generic CustomEvent.""" + + @executor(id="thinker") + async def thinker(message: Any, ctx: WorkflowContext[str, str]) -> None: + # Intermediate-designated executor: text should render as reasoning. + await ctx.yield_output("Analyzing the problem...") + await ctx.send_message("go") + + @executor(id="finalizer") + async def finalizer(message: str, ctx: WorkflowContext[None, str]) -> None: + # Output-designated executor: final assistant text. + await ctx.yield_output("Here's my answer!") + + workflow = ( + WorkflowBuilder( + start_executor=thinker, + output_from=[finalizer], + intermediate_output_from=[thinker], + ) + .add_edge(thinker, finalizer) + .build() + ) + input_data = {"messages": [{"role": "user", "content": "solve it"}]} + + events = [event async for event in run_workflow_stream(input_data, workflow)] + event_types = [event.type for event in events] + + # The intermediate text renders as reasoning ... + assert "REASONING_MESSAGE_CONTENT" in event_types + reasoning_deltas = [event.delta for event in events if event.type == "REASONING_MESSAGE_CONTENT"] # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + assert "Analyzing the problem..." in reasoning_deltas + + # ... and is not swallowed by the generic custom-event fallback. + assert not [event for event in events if event.type == "CUSTOM" and event.name == "intermediate"] # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + # The final output still renders as an assistant text message. + assert "TEXT_MESSAGE_CONTENT" in event_types + text_deltas = [event.delta for event in events if event.type == "TEXT_MESSAGE_CONTENT"] # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + assert "Here's my answer!" in text_deltas + + +async def test_workflow_run_maps_data_alias_text_to_reasoning_events(): + """The deprecated ``type='data'`` alias is treated like ``intermediate`` and renders as reasoning.""" + + @executor(id="emitter") + async def emitter(message: Any, ctx: WorkflowContext[Any, str]) -> None: + # Deprecated compatibility alias for an intermediate emission. + await ctx.add_event(WorkflowEvent.emit("emitter", "legacy reasoning")) + await ctx.yield_output("final answer") + + workflow = WorkflowBuilder(start_executor=emitter).build() + input_data = {"messages": [{"role": "user", "content": "go"}]} + + with pytest.warns(DeprecationWarning): + events = [event async for event in run_workflow_stream(input_data, workflow)] + + reasoning_deltas = [event.delta for event in events if event.type == "REASONING_MESSAGE_CONTENT"] # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + assert "legacy reasoning" in reasoning_deltas + assert not [event for event in events if event.type == "CUSTOM" and event.name == "data"] # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + # The genuine output is still emitted as an assistant text message. + text_deltas = [event.delta for event in events if event.type == "TEXT_MESSAGE_CONTENT"] # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + assert "final answer" in text_deltas + + +def test_as_reasoning_content_preserves_protected_data(): + """Re-tagging text as reasoning keeps encrypted protected_data and passes non-text through.""" + text = Content("text", text="thinking", protected_data="enc-blob") + + reasoning = _as_reasoning_content(text) + + assert reasoning.type == "text_reasoning" + assert reasoning.text == "thinking" + # Without this the ReasoningEncryptedValueEvent and snapshot encryptedValue are lost. + assert reasoning.protected_data == "enc-blob" + + # Non-text content (e.g. a tool call) is returned unchanged. + call = Content.from_function_call(call_id="c1", name="tool", arguments="{}") + assert _as_reasoning_content(call) is call + + +async def test_workflow_run_closes_reasoning_before_run_finished(): + """Intermediate reasoning with no terminal text still closes before the terminal event.""" + + @executor(id="thinker") + async def thinker(message: Any, ctx: WorkflowContext[Any, str]) -> None: + # Intermediate output only -- no terminal assistant text follows. + await ctx.yield_output("Thinking, but no final answer...") + + workflow = WorkflowBuilder( + start_executor=thinker, + output_from=[], + intermediate_output_from=[thinker], + ).build() + input_data = {"messages": [{"role": "user", "content": "go"}]} + + events = [event async for event in run_workflow_stream(input_data, workflow)] + event_types = [event.type for event in events] + + assert "REASONING_END" in event_types + assert "RUN_FINISHED" in event_types + run_finished_idx = next(i for i, event in enumerate(events) if event.type == "RUN_FINISHED") + # Every reasoning event -- including the closing REASONING_MESSAGE_END / REASONING_END -- + # must precede RUN_FINISHED so clients that stop at the terminal event get a complete stream. + reasoning_idxs = [i for i, event in enumerate(events) if "REASONING" in str(event.type)] + assert reasoning_idxs + assert max(reasoning_idxs) < run_finished_idx + + +async def test_workflow_run_closes_reasoning_before_request_info(): + """An open reasoning block is closed before a request_info tool call (not left spanning it).""" + + @executor(id="asker") + async def asker(message: Any, ctx: WorkflowContext[Any, str]) -> None: + # Intermediate reasoning, then a human-in-the-loop request in the same run. + await ctx.yield_output("Thinking before I ask...") + await ctx.request_info("Need approval", str, request_id="approval-1") + + workflow = WorkflowBuilder( + start_executor=asker, + output_from=[], + intermediate_output_from=[asker], + ).build() + input_data = {"messages": [{"role": "user", "content": "go"}]} + + events = [event async for event in run_workflow_stream(input_data, workflow)] + event_types = [event.type for event in events] + + assert "REASONING_END" in event_types + reasoning_end_idx = max(i for i, event in enumerate(events) if "REASONING" in str(event.type)) + request_start_idx = next( + i + for i, event in enumerate(events) + if event.type == "TOOL_CALL_START" and getattr(event, "tool_call_id", None) == "approval-1" + ) + # The reasoning block must be fully closed before the request_info tool call. + assert reasoning_end_idx < request_start_idx + + +async def test_workflow_run_roleless_intermediate_update_becomes_reasoning(): + """A role-less AgentResponseUpdate on the intermediate path surfaces its text as reasoning.""" + + @executor(id="thinker") + async def thinker(message: Any, ctx: WorkflowContext[str, Any]) -> None: + # role=None is the common shape for streamed continuation chunks. + await ctx.yield_output(AgentResponseUpdate(contents=[Content.from_text("Role-less thought")], role=None)) + await ctx.send_message("go") + + @executor(id="finalizer") + async def finalizer(message: str, ctx: WorkflowContext[None, str]) -> None: + await ctx.yield_output("Done.") + + workflow = ( + WorkflowBuilder( + start_executor=thinker, + output_from=[finalizer], + intermediate_output_from=[thinker], + ) + .add_edge(thinker, finalizer) + .build() + ) + input_data = {"messages": [{"role": "user", "content": "go"}]} + + events = [event async for event in run_workflow_stream(input_data, workflow)] + + reasoning_deltas = [event.delta for event in events if event.type == "REASONING_MESSAGE_CONTENT"] # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + assert "Role-less thought" in reasoning_deltas + # The role-less text must not be dropped into a generic custom event. + assert not [event for event in events if event.type == "CUSTOM" and event.name == "workflow_output"] # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + async def test_workflow_and_agent_spans_use_supplied_agui_thread_id(monkeypatch: pytest.MonkeyPatch) -> None: """Workflow spans use supplied AG-UI threads without replacing provider fallback behavior.""" import agent_framework.observability as observability @@ -1838,9 +2011,10 @@ def test_agent_response_update_non_assistant(self): assert _workflow_payload_to_contents(update) is None def test_agent_response_update_none_role(self): - """AgentResponseUpdate with None role returns None.""" - update = AgentResponseUpdate(contents=[Content.from_text(text="hi")], role=None) - assert _workflow_payload_to_contents(update) is None + """AgentResponseUpdate with None role keeps text (role-less continuation chunks).""" + text = Content.from_text(text="hi") + update = AgentResponseUpdate(contents=[text], role=None) + assert _workflow_payload_to_contents(update) == [text] def test_agent_response_update_function_call_without_role(self) -> None: """Function call content passes through without role metadata.""" @@ -1883,11 +2057,19 @@ def test_agent_response_update_mcp_tool_result_without_role(self) -> None: assert _workflow_payload_to_contents(update) == [mcp_result] def test_agent_response_update_mixed_content_without_role(self) -> None: - """Non-assistant updates keep tool content and drop text content.""" + """Role-less updates keep both text and tool content in order.""" text = Content.from_text(text="calling the tool") function_call = Content.from_function_call(call_id="call-1", name="search", arguments={"query": "weather"}) update = AgentResponseUpdate(contents=[text, function_call], role=None) + assert _workflow_payload_to_contents(update) == [text, function_call] + + def test_agent_response_update_explicit_non_assistant_role_drops_text(self) -> None: + """An explicit non-assistant role still keeps only tool content and drops text.""" + text = Content.from_text(text="calling the tool") + function_call = Content.from_function_call(call_id="call-1", name="search", arguments={"query": "weather"}) + update = AgentResponseUpdate(contents=[text, function_call], role="tool") + assert _workflow_payload_to_contents(update) == [function_call] def test_agent_response_update_assistant_text(self) -> None: From eff067447d25c1e35804dc7686d94072b03c87d3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:02:45 +0000 Subject: [PATCH 21/85] Chore(deps): Bump actions/github-script from 8.0.0 to 9.0.0 (#7582) Bumps [actions/github-script](https://github.com/actions/github-script) from 8.0.0 to 9.0.0. - [Release notes](https://github.com/actions/github-script/releases) - [Commits](https://github.com/actions/github-script/compare/ed597411d8f924073f98dfc5c65a23a2325f34cd...3a2844b7e9c422d3c10d287c895573f7108da1b3) --- updated-dependencies: - dependency-name: actions/github-script dependency-version: 9.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/devflow-pr-review.yml | 6 +++--- .github/workflows/dotnet-build-and-test.yml | 4 ++-- .github/workflows/integration-tests-manual.yml | 2 +- .github/workflows/issue-triage.yml | 4 ++-- .github/workflows/label-issues.yml | 2 +- .github/workflows/label-pr.yml | 2 +- .github/workflows/label-title-prefix.yml | 2 +- .github/workflows/limit-community-prs.yml | 4 ++-- .github/workflows/merge-gatekeeper.yml | 2 +- .github/workflows/python-dependency-maintenance.yml | 6 +++--- .github/workflows/python-integration-tests.yml | 4 ++-- .github/workflows/python-merge-tests.yml | 4 ++-- 12 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.github/workflows/devflow-pr-review.yml b/.github/workflows/devflow-pr-review.yml index 03eddad556c..48a2b7acefa 100644 --- a/.github/workflows/devflow-pr-review.yml +++ b/.github/workflows/devflow-pr-review.yml @@ -86,7 +86,7 @@ jobs: - name: Check review command id: check - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const isReviewCommand = require('./.github/scripts/review_command.js'); @@ -163,7 +163,7 @@ jobs: - name: Check review requester team membership id: check - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: MEMBERSHIP_USER: >- ${{ @@ -194,7 +194,7 @@ jobs: - name: React to authorized review command if: ${{ github.event_name == 'issue_comment' && steps.check.outputs.is_team_member == 'true' }} - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ steps.github-auth.outputs.token }} script: | diff --git a/.github/workflows/dotnet-build-and-test.yml b/.github/workflows/dotnet-build-and-test.yml index 90b5bff88ed..920cce62a47 100644 --- a/.github/workflows/dotnet-build-and-test.yml +++ b/.github/workflows/dotnet-build-and-test.yml @@ -640,14 +640,14 @@ jobs: - name: Fail workflow if tests failed id: check_tests_failed if: contains(join(needs.*.result, ','), 'failure') - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: core.setFailed('Integration Tests Failed!') - name: Fail workflow if tests cancelled id: check_tests_cancelled if: contains(join(needs.*.result, ','), 'cancelled') - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: core.setFailed('Integration Tests Cancelled!') diff --git a/.github/workflows/integration-tests-manual.yml b/.github/workflows/integration-tests-manual.yml index 8a6d415e814..aca4522a6ea 100644 --- a/.github/workflows/integration-tests-manual.yml +++ b/.github/workflows/integration-tests-manual.yml @@ -50,7 +50,7 @@ jobs: - name: Resolve and authorize checkout ref id: resolve - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml index 3b6d82f98a3..561ff4d0ea3 100644 --- a/.github/workflows/issue-triage.yml +++ b/.github/workflows/issue-triage.yml @@ -96,7 +96,7 @@ jobs: - name: Check issue author team membership if: ${{ github.event_name != 'workflow_dispatch' }} id: check - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }} ISSUE_NUMBER: ${{ steps.issue.outputs.issue_number }} @@ -120,7 +120,7 @@ jobs: - name: Resolve native issue type id: issue-type - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: ISSUE_NUMBER: ${{ steps.issue.outputs.issue_number }} with: diff --git a/.github/workflows/label-issues.yml b/.github/workflows/label-issues.yml index d420e922d1f..d514c7a38f0 100644 --- a/.github/workflows/label-issues.yml +++ b/.github/workflows/label-issues.yml @@ -40,7 +40,7 @@ jobs: repository: ${{ github.repository }} fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }} - - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ steps.github-auth.outputs.token }} script: | diff --git a/.github/workflows/label-pr.yml b/.github/workflows/label-pr.yml index 165811175d8..4771bc538af 100644 --- a/.github/workflows/label-pr.yml +++ b/.github/workflows/label-pr.yml @@ -51,7 +51,7 @@ jobs: repo-token: ${{ steps.github-auth.outputs.token }} - name: "PR: add breaking change label from title" - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ steps.github-auth.outputs.token }} script: | diff --git a/.github/workflows/label-title-prefix.yml b/.github/workflows/label-title-prefix.yml index a830b8aa13b..3a9cd542de9 100644 --- a/.github/workflows/label-title-prefix.yml +++ b/.github/workflows/label-title-prefix.yml @@ -22,7 +22,7 @@ jobs: fetch-depth: 1 persist-credentials: false - - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 name: "Issue/PR: update title" with: github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/limit-community-prs.yml b/.github/workflows/limit-community-prs.yml index e2e11f902e7..12e40018612 100644 --- a/.github/workflows/limit-community-prs.yml +++ b/.github/workflows/limit-community-prs.yml @@ -53,7 +53,7 @@ jobs: - name: Check PR author team membership id: check - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }} PR_NUMBER: ${{ github.event.pull_request.number }} @@ -107,7 +107,7 @@ jobs: fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }} - name: Enforce open PR limit - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ steps.github-auth.outputs.token }} script: | diff --git a/.github/workflows/merge-gatekeeper.yml b/.github/workflows/merge-gatekeeper.yml index cf55dbeb49f..8aadcace48b 100644 --- a/.github/workflows/merge-gatekeeper.yml +++ b/.github/workflows/merge-gatekeeper.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Wait for required checks if: github.event_name == 'pull_request' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: TIMEOUT_SECONDS: "3600" INTERVAL_SECONDS: "30" diff --git a/.github/workflows/python-dependency-maintenance.yml b/.github/workflows/python-dependency-maintenance.yml index 7f84170a57e..c680e594ccc 100644 --- a/.github/workflows/python-dependency-maintenance.yml +++ b/.github/workflows/python-dependency-maintenance.yml @@ -88,7 +88,7 @@ jobs: - name: Create issue for failed dependency bounds test if: steps.validate_bounds_test.outcome != 'success' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -158,7 +158,7 @@ jobs: - name: Create issues for failed dependency candidates if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -321,7 +321,7 @@ jobs: - name: Create or update dependency maintenance tracking issue if: steps.commit_updates.outputs.has_changes == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/python-integration-tests.yml b/.github/workflows/python-integration-tests.yml index acfd0092087..257a523d6da 100644 --- a/.github/workflows/python-integration-tests.yml +++ b/.github/workflows/python-integration-tests.yml @@ -559,12 +559,12 @@ jobs: steps: - name: Fail workflow if tests failed if: contains(join(needs.*.result, ','), 'failure') - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: core.setFailed('Integration Tests Failed!') - name: Fail workflow if tests cancelled if: contains(join(needs.*.result, ','), 'cancelled') - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: core.setFailed('Integration Tests Cancelled!') diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml index 4d77ef06608..17b338ab81b 100644 --- a/.github/workflows/python-merge-tests.yml +++ b/.github/workflows/python-merge-tests.yml @@ -720,13 +720,13 @@ jobs: - name: Fail workflow if tests failed id: check_tests_failed if: contains(join(needs.*.result, ','), 'failure') - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: core.setFailed('Integration Tests Failed!') - name: Fail workflow if tests cancelled id: check_tests_cancelled if: contains(join(needs.*.result, ','), 'cancelled') - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: core.setFailed('Integration Tests Cancelled!') From b521cbd535cc222cc7a80d18c2bf3d1112cf5819 Mon Sep 17 00:00:00 2001 From: Anton Sokolovskyi Date: Fri, 4 Sep 2026 10:41:11 +0000 Subject: [PATCH 22/85] .NET: [BREAKING] Add file_access_read_lines and move the line-numbering contract onto AgentFileStore (#7671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * .NET: [BREAKING] Add file_access_read_lines and align grep with the line editor The harness file tools were line-precise when editing (file_access_replace_lines takes 1-based line numbers and file_access_grep reports them) but all-or-nothing when reading, so there was no way to see the lines around a match without reading the whole file. Adds file_access_read_lines, rendering each row as `\t` with everything after the tab verbatim, including the line's own terminator, so a row feeds straight back into file_access_replace_lines. That contract only holds if grep and the line editor agree on what a line is, and they did not. The stores split on '\n' and stripped '\r'; FileEditor split on '\n', '\r\n' and a lone '\r' and kept terminators. So on a file using lone '\r' terminators, grep's line 1 addressed only part of what the model was shown and editing by that number silently changed the wrong text, and on a newline-terminated file grep could report a trailing line number the editor rejected as out of range. Both stores now use FileEditor.SplitLinesKeepEnds and report the matching line verbatim. BREAKING: FileSearchMatch.Line now includes the line's terminator, and line numbers change on content containing a lone '\r' or a trailing newline. This affects file_access_grep and file_memory_grep. The whole surface is [Experimental("MAAI001")]. Co-Authored-By: Claude Opus 5 (1M context) * .NET: Strip the whole line terminator before matching, and name args as the schema does Addresses both review comments on #7671. TrimTrailingNewline removed only "\n", so grep matched against text such as "match\r" on CRLF and lone-CR lines and an end-anchored pattern like "match$" failed even though the line's text was exactly "match". Renamed to TrimLineTerminator and it now strips "\r\n", "\n", or a lone "\r". The file_access_read_lines description and the SliceLines failure messages referred to end_line/start_line, but the generated schema exposes the arguments as endLine/startLine, so the model could be prompted to emit an invalid argument name. Both now use the schema's names. (new_line is left as-is: FileLineEdit sets it explicitly via JsonPropertyName.) Co-Authored-By: Claude Opus 5 (1M context) * .NET: Cover the file-system store's search loop and scope the line-number claim FileSystemAgentFileStore carries its own copy of the search loop, and its tests asserted only a line number of 1 and Assert.Contains on the snippet — no test in that file ever asserted a FileSearchMatch.Line value. Reverting its loop to Split('\n') with TrimEnd('\r') left the whole suite green, so the store that touches real files had no coverage of anything this branch changed. Mirrors the six search tests from InMemoryAgentFileStoreTests; six of the eight new cases fail against the reverted loop, and the snippet test fails on its own if the per-line advance regains the "+ 1" that the terminator-keeping split made wrong. Also scopes the parity claim to the stores in this package. file_access_grep runs through the public AgentFileStore.SearchAsync, whose contract says nothing about how content is split or whether terminators survive, while read_lines and replace_lines split through FileEditor — which is internal, so a custom store cannot reuse it even deliberately. Promising that the numbers always agree is therefore something this provider cannot honour. FileEditor.SplitLinesKeepEnds, FileSearchMatch.Line and ReadLinesAsync now say where the guarantee holds and where it does not. The tool's [Description] is left unhedged on purpose: it is prompt text, and teaching the model to doubt the line numbers would send it back to whole-file reads, which is the cost this branch exists to remove. Co-Authored-By: Claude Opus 5 (1M context) * .NET: Drop the parity promise from the read_lines description and stop copying every line Two findings from Copilot review 4950508867. The tool description told the model, unconditionally, that grep's line numbers match read_lines and replace_lines. That text reaches the model, and it is the sentence that licenses going straight from grep to replace_lines without reading the range — which is exactly the wrong-line edit this branch exists to prevent when the store is a custom one, since AgentFileStore.SearchAsync prescribes no split. Removed. The grep -> read_lines -> replace_lines workflow in DefaultInstructions stays: it routes through a read whose numbering shares FileEditor with the editor, so the model sees the text it is about to change. TrimLineTerminator copied every line before knowing whether it matched, so a search over a newline-heavy file allocated a second copy of nearly all its text on top of SplitLinesKeepEnds. It becomes LineContentLength, and both stores now call Regex.Match(line, 0, length), which bounds the match without copying. Verified equivalent across four line shapes and four patterns, match.Index included, so the snippet offsets are untouched. LineContentLength_BoundsAnEndAnchoredMatch pins the bound; on CRLF and lone-CR lines it fails without it, while the LF and unterminated cases pass either way because .NET's '$' already matches before a trailing newline. Co-Authored-By: Claude Opus 5 (1M context) * .NET: [BREAKING] Move the line-numbering contract onto AgentFileStore Mirrors the Python change in #7669 so the two SDKs keep the same contract. file_access_grep took its line numbers from the store, while read_lines and replace_lines re-counted them from ReadAsync. SearchAsync was abstract with no numbering contract, so a custom store could make "line 5" mean two different things and replace_lines would edit the wrong line, in range, reporting success. FileMemoryProvider had the same hole. The rule now lives on the base class: - SplitLines() publishes the split every line_number addresses. Per-SDK: it is not required to match Python, only to be consistent here, because a line number never crosses runtimes. - ScanContent() is the numbering primitive. Both shipped stores now report through it, which also removes the scan loop that was duplicated between them. - FindMatchingFilesAsync() is a new hook for narrowing the search to the files worth reading. The regex goes down as a hint with superset semantics: over-returning is harmless because the base re-scans, under-returning loses matches. A backend with a native index overrides it and narrows server-side. Its default works, built from ListChildrenAsync, so a store that implements nothing beyond the mandatory members now gets aligned numbers for free. - SearchAsync is no longer abstract. It reads and numbers candidates itself, and re-applies the glob and the non-recursive rule, since the hook may over-return. Overriding SearchAsync stays first-class -- a backend that can do the whole job natively should -- but then it owns numbering, and both providers verify it: each reported line is re-matched against the line the editor would touch, and the whole call is refused on a mismatch. It compares by pattern rather than by string, because a custom store is not bound to report the line verbatim and comparing text would reject correct stores. Two ways to opt out: a store sets ReportsAlignedLineNumbers, or a provider takes DisableSearchAlignmentCheck. The store flag is narrower and preferred. Both are promises rather than hints, and a test pins that hazard on purpose. FileLineEdit also gains an optional ExpectedLine. When supplied, the edit is refused unless the target line still says what the caller saw, which catches splitter drift, a stale line number, and the file changing between read and write. Two things differ from the Python half, both deliberate: No batching. Both stores already construct the regex with a match timeout, so ReDoS protection is in the engine and there is no thread offload to amortise. Python needs one; this does not. No reflection. Detecting whether a store overrides SearchAsync via GetType().GetMethod trips IL2075 and is not trim-safe, so the base implementation records that it ran instead -- which is also more accurate, being set by the call that produced the results. BREAKING: SearchAsync is no longer abstract, and a store whose line numbers disagree with SplitLines now has its grep results refused rather than silently applied. Removing abstract while keeping the member virtual passes Package Validation; verified by building the package in Release. Co-Authored-By: Claude Opus 5 (1M context) * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * .NET: Fix two holes in the search alignment check and four stale contract docs From Copilot's review of the rebased head (review 4983864371). SearchAlignment validated only the upper bound of a reported line number, so a store reporting 0 or a negative one indexed out of range and surfaced a raw ArgumentOutOfRangeException instead of the misalignment error the check exists to report. Both bounds are checked now. A RegexMatchTimeoutException on one line returned from the whole check, so every later match and file in the call was handed to the model as though it had passed verification. It now skips that match and keeps going. Python already fails closed here via asyncio.wait_for, so this was a .NET-only divergence. Four doc comments still described the design as it was before the contract commit, telling the reader the opposite of what the code now does: - FileAccessProvider read_lines remarks said grep's contract does not prescribe a split, so a custom store may report incompatible numbers. It does prescribe one, and results that disagree are refused. - FileSearchMatch said a custom store is not held to either property. It is not held to the text, but the number must address SplitLines. - FileEditor.SplitLinesKeepEnds said a custom SearchAsync is not required to use this split. It is required to; it just does not inherit it. - FindMatchingFilesAsync told implementers a dialect mismatch costs recall and nothing else, contradicting the superset rule three lines above it. Recall loss is precisely the failure the rule forbids, so it now says to widen rather than guess. The narrowing test deliberately under-returns from the hook, which is the very violation the contract names. That is the only way to prove the hook decided what got read, and the comment now says so rather than appearing to bless it. Two new tests, each confirmed to fail without its fix. Reaching the timeout path needs a budget shorter than the five-second default, so ThrowIfMisalignedAsync takes an optional per-line timeout; nothing outside the tests passes it. Two findings from the same review are deliberately not actioned. The expected_line mismatch message reveals the current line, which is a read oracle where write tools are auto-approved and read tools are not; removing it defeats the message's purpose, and Python carries the identical text, so that is a maintainer call. The ADR request is likewise left open: the requirement is stated in .github/copilot-instructions.md rather than CONTRIBUTING.md, and the process needs deciders an external contributor cannot nominate. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Mo3qRizH73Gtpviqf5BjP7 * .NET: State the line-counting rule in the tools and give memory its own refusal Mirrors the Python changes in #7669, which came out of @westey-m's review there. The tool descriptions never said how lines are counted. A model that reads a whole file with file_access_read and then edits by number has to count them itself, and nothing told it the rule. read, read_lines, replace_lines and grep now state it, as do the two memory tools that take or report line numbers. The rule is deliberately not the one Python states, and the wording must not be copied between the two SDKs. Here a lone \r terminates a line and content ending in a terminator has no trailing empty line; in Python neither is true. Taken from the cases FileEditorTests already pins rather than from reading the splitter. SearchAlignment's refusal is shared by both providers and names read_lines, which FileMemoryProvider does not register -- there is no file_memory_read_lines at all. ThrowIfMisalignedAsync now takes the message from the caller, defaulting to the existing wording, and the memory provider passes one naming file_memory_read. CreateToolsAsync in the memory tests was typed to InMemoryAgentFileStore, which is sealed, so a skewed double could not be passed to it. Widened to AgentFileStore. The new test was confirmed to fail against the file-access wording. Removing the call argument instead only breaks the build, which shows the constant has one consumer but not that the assertion discriminates. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Mo3qRizH73Gtpviqf5BjP7 * .NET: Make the base-results trust marker prove integrity, not just provenance From Copilot review 4985186414. BaseSearchResults marked a list as numbered by the base SearchAsync, and IsTrusted skipped verification on the strength of the type alone. Everything in the payload is mutable -- the list itself, FileSearchResult.FileName and MatchingLines, FileSearchMatch.LineNumber -- so an override could await base.SearchAsync, renumber the results in place, and return the very same instance. The marker survived, verification was skipped, and the wrong-line edit it exists to prevent was reachable again. This is not only a hostile-store concern. A store that prepends a header in ReadAsync and "corrects" the base's numbers to compensate is the same shape as the Python defect fixed in #7669, and it would have kept full trust here. The marker now snapshots the file names and line numbers it was constructed with, and IsUnmodified re-checks them before trust is granted. The tag says who built the list; the snapshot says the numbers in it are still theirs. Comparison is exact rather than hashed, because a collision would mean false trust, which is the one direction this must not fail in. A modified list drops to verification rather than failing outright, matching the conservative posture elsewhere. FileSearchMatch.Line is deliberately excluded. A custom store may report the text differently -- that is why the check matches by pattern rather than by string -- so covering it would reject stores doing something the contract permits. The type's own documentation claimed an override that post-processes the base results loses the tag. That was true only of post-processing into a new list, and is corrected here. Python needs no equivalent: _numbers_are_trusted keys on the store type and the identity of the base search function, neither of which a store can mutate. This gap existed only because .NET routes around GetType().GetMethod for trim-safety. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Mo3qRizH73Gtpviqf5BjP7 * .NET: Replace the runtime alignment check with a documented contract From @westey-m's review. Policing implementations that are not per spec is not worth the cost at runtime; the expectation belongs on the type an implementer is reading when they get it wrong. A conformance test library, which is the real answer for checking an implementation, is tracked separately as #7931. SearchAlignment and BaseSearchResults are deleted, along with ReportsAlignedLineNumbers and its two overrides and DisableSearchAlignmentCheck on both provider options. SearchAsync returns its results directly instead of a tagged list, and neither provider verifies what a store reports. The contract those types enforced now sits on SearchAsync itself: a store overriding it must number FileSearchMatch.LineNumber as a 1-based coordinate into SplitLines of the content ReadAsync returns, ScanContent builds that correctly, and numbering against anything else is a bug whose failure mode is silent -- the search looks right, and the damage appears when a later line edit applies to a line the caller never saw. Two doc comments elsewhere still said the tools verify the numbering, which is no longer true of either. The FileSearchMatch and FileEditor remarks also scoped the rule to the stores shipped here rather than stating what implementers owe; both now read as expectations, which the same review asked for. Descriptions that state the line-counting rule now also say the numbers are 1-based, so a model counting lines from a whole-file read knows where to start. Eight tests went with the check: seven alignment cases and their six helper stores in AgentFileStoreContractTests, and the memory-refusal test with its skewed store in FileMemoryProviderTests. Nothing exercised the removed types otherwise; the remaining 2099 unit tests pass. Co-Authored-By: Claude Opus 5 (1M context) * .NET: Drop two usings left dead by the alignment-check removal From Copilot review 5051570368. Both are fallout from 72a1f0344 rather than pre-existing: removing SkewedMemoryStore took the last CancellationToken out of FileMemoryProviderTests, and removing the shipped-stores trust test took the last Path.GetTempPath() out of AgentFileStoreContractTests, leaving System.Threading and System.IO imported for nothing. Neither surfaced in the build because unused usings are not an error here. Co-Authored-By: Claude Opus 5 (1M context) * .NET: Pin the snippet offset for every terminator, and drop a stale test rationale From Copilot review 5052352338. SearchFiles_SnippetIsAnchoredAtTheMatch exercised the per-line offset with LF only, so it would have passed had ScanContent advanced by content length plus one rather than by the line's own length -- correct for LF and a lone CR, a character short for CRLF. It is now a theory over all three, and the expected snippet derives from the terminator length instead of hardcoding 49 padding characters. Confirmed by making that exact substitution in ScanContent: the CRLF case fails and the other two pass, which is what the single-Fact version could not see. The comment above the mirrored filesystem search tests still said that store carries its own copy of the search loop. It does not: both stores now number through AgentFileStore.ScanContent, which was one of the goals of this PR, so the rationale contradicted the code. Reworded as what those tests actually add -- the same rules exercised against real files, where content arrives through a decoded read rather than an in-memory string. Co-Authored-By: Claude Opus 5 (1M context) * .NET: Cover the read_lines approval group in the isolated option tests From the suppressed comment on Copilot review 5053203365. DisableReadOnlyToolApproval_ReadOnlyToolsNotWrapped and its write counterpart enumerated read, ls and grep on one side and the four store-modifying tools on the other, but never read_lines. Nothing else discriminated which group it is wired into: the auto-approval theory covers a different mechanism, and DisableBothToolApprovals only counts tools. Wiring read_lines to writeRequiresApproval would therefore have reversed the approval boundary for a read tool with every test still green. Both tests now assert it alongside the other read-only tools. Confirmed by making that substitution in FileAccessProvider: both fail, and the other 2114 tests pass, which is what makes the gap the finding describes visible rather than theoretical. Co-Authored-By: Claude Opus 5 (1M context) * .NET: Say which part of a numbered read expected_line takes From the suppressed comment on Copilot review 5053896798. The expected_line description told the caller to use the text "as reported by grep or read_lines". read_lines prefixes each line with its number and a tab, so a model following that literally can pass the whole row, which never matches: the editor compares the line's own text. It now says to give the line text only and names the prefix it must not include. read_lines is also the wrong tool to name here at all. FileLineEdit is shared by FileAccessProvider and FileMemoryProvider, and the memory provider registers no read_lines tool, so half the callers were pointed at something that does not exist for them. grep is named instead, which both providers do register. The XML doc above the property carried the same gap and now states the rule too, since that is what an implementer reads rather than the tool description. Co-Authored-By: Claude Opus 5 (1M context) * .NET: State the hook's case semantics, and stop claiming containment on a mismatch From the two suppressed comments on Copilot review 5058964476. FindMatchingFilesAsync took the regex and the glob as hints without saying how either is compared. SearchAsync builds its regex with RegexOptions.IgnoreCase and StorePaths.CreateGlobMatcher uses OrdinalIgnoreCase, so a backend pushing either hint into a case-sensitive index returns only case-exact candidates and drops matches before the base ever re-scans. That is the under-return the superset rule exists to forbid, and nothing in the contract warned against it. Both parameters now state the comparison and require widening when the backend cannot reproduce it. The expected_line mismatch said the line "does not contain the expected text" while the check is exact equality after trimming terminators. For actual foobar and expected foo the line does contain it, so the model was handed a diagnostic that contradicts what it can see and would send it re-reading a file that had not changed. It now says the line does not match, and the contract test that pinned the old wording follows. Confirmed the assertion discriminates by putting the old wording back: ApplyReplaceLines_ExpectedLineDiffering_Throws fails and the other 2115 pass. Co-Authored-By: Claude Opus 5 (1M context) * .NET: Say what FindMatchingFilesAsync narrows on, and why a store would override it From @westey-m's review. The summary described the hook by the regex alone, which reads as though the glob were someone else's concern. It now names both: files that may contain text matching the pattern, and whose names may match the glob. Taken from the suggestion, with the missing space after restored. The remarks said what the default implementation does but never why anyone would replace it. They now state the purpose: override when the backing store can answer either question more cheaply -- a name index for the glob, a content or full-text index for the regex -- with the store doing the narrowing it is good at and the base keeping the scanning and the line numbering. Co-Authored-By: Claude Opus 5 (1M context) * .NET: Declare this PR's API surface for the new public API analyzers Build fix, not a change in behaviour. The analyzers landed on main in 0822dd749 after this branch was last built, so every member this PR adds or changes was undeclared: RS0016 for the seven new symbols, and RS0017 for the shipped abstract SearchAsync, which this PR made virtual and which therefore no longer exists as declared. All eight are recorded in PublicAPI.Unshipped.txt for the five target frameworks, which stay byte-identical to each other as they were before. The retired signature is marked with *REMOVED* rather than deleted from PublicAPI.Shipped.txt. That is the analyzer's own mechanism for retiring a shipped member, and it keeps the abstract-to-virtual change visible as a line in this PR's diff instead of a silent deletion from a shipped baseline. Nothing in the repo has needed it before, since the analyzers are three days old. Reproduced locally first: the same eighty RS001x errors CI reported, now zero in both Debug and Release, with 2116 unit tests passing. Co-Authored-By: Claude Opus 5 (1M context) * .NET: Stop the default tree walk when the token is already cancelled From Copilot review 5110306826. FindMatchingFilesAsync passed the token to ListChildrenAsync but never checked it itself, so a store whose listing ignores the token kept the walk going after cancellation, one listing per directory. The contract-test store is exactly that shape: it takes a CancellationToken and never reads it. SearchAsync then threw on its first candidate, which hid the wasted work behind a correct-looking exception. The walk now checks before each listing and before each entry. BaseSearch_StopsWalkingWhenCancelledEvenIfTheStoreIgnoresTheToken pins it by counting listings rather than by asserting an exception: with the fix zero listings happen, and without it twenty-one. The first version of this test asserted only that OperationCanceledException was thrown and passed against the unfixed code, because SearchAsync throws either way -- the exception was never the thing worth measuring. Co-Authored-By: Claude Opus 5 (1M context) * .NET: Guard ScanContent's fileName alongside its other parameters From the suppressed comment on Copilot review 5110771736. ScanContent validated content and regex and left fileName alone, so a null went through and landed in FileSearchResult.FileName, which is a non-nullable string. The gap only shows when the scan matches: with no match the method returns null and the bad argument never surfaces, which is why nothing had caught it on a member this PR makes public. ScanContent_NullFileName_Throws pins it with a pattern that does match, for that reason. Without the guard it fails on "No exception was thrown" rather than on a wrong exception type, which is the defect rather than a near miss. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Claw_Step02_WorkingWithData/README.md | 3 +- .../Harness_Step03_DataProcessing/README.md | 3 +- .../Harness/FileAccess/FileAccessProvider.cs | 70 ++++- .../FileAccess/FileAccessProviderOptions.cs | 7 +- .../Harness/FileMemory/FileMemoryProvider.cs | 4 +- .../Harness/FileStore/AgentFileStore.cs | 207 +++++++++++++- .../Harness/FileStore/FileEditor.cs | 94 ++++++- .../Harness/FileStore/FileLineEdit.cs | 11 + .../Harness/FileStore/FileSearchMatch.cs | 8 +- .../FileStore/FileSystemAgentFileStore.cs | 39 +-- .../FileStore/InMemoryAgentFileStore.cs | 40 +-- .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 8 + .../PublicAPI/net472/PublicAPI.Unshipped.txt | 8 + .../PublicAPI/net8.0/PublicAPI.Unshipped.txt | 8 + .../PublicAPI/net9.0/PublicAPI.Unshipped.txt | 8 + .../netstandard2.0/PublicAPI.Unshipped.txt | 8 + .../HarnessAgentTests.cs | 1 + .../FileAccess/FileAccessProviderTests.cs | 183 ++++++++++++- .../FileMemory/FileMemoryProviderTests.cs | 4 +- .../FileStore/AgentFileStoreContractTests.cs | 257 ++++++++++++++++++ .../Harness/FileStore/FileEditorTests.cs | 127 +++++++++ .../FileSystemAgentFileStoreTests.cs | 106 ++++++++ .../FileStore/InMemoryAgentFileStoreTests.cs | 97 ++++++- 23 files changed, 1206 insertions(+), 95 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/AgentFileStoreContractTests.cs diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step02_WorkingWithData/README.md b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step02_WorkingWithData/README.md index 9755e8ba6b7..892d346c4f4 100644 --- a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step02_WorkingWithData/README.md +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step02_WorkingWithData/README.md @@ -17,7 +17,8 @@ It builds on Post 1's personal finance assistant and teaches it to work with *yo > ⚠️ **Security — avoid tool-name collisions:** auto-approval rules such as > `FileAccessProvider.ReadOnlyToolsAutoApprovalRule` match tool calls **solely by tool name**. Any - > other registered tool that shares one of the approved names (`file_access_read`, `file_access_ls`, + > other registered tool that shares one of the approved names (`file_access_read`, + > `file_access_read_lines`, `file_access_ls`, > `file_access_grep`) would be silently auto-approved, bypassing the human > approval boundary. Ensure no other tool's name collides with the reserved names a rule approves. - **Durable memory, two ways:** diff --git a/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/README.md b/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/README.md index 02b15847e9b..2415efdb3be 100644 --- a/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/README.md +++ b/dotnet/samples/02-agents/Harness/Harness_Step03_DataProcessing/README.md @@ -55,7 +55,8 @@ E.g. try the following prompt `Please process the sales.csv file by first filter This sample uses `FileAccessProvider.ReadOnlyToolsAutoApprovalRule` to auto-approve read-only file access tools. Built-in auto-approval rules match tool calls **solely by tool name**, so any other -registered tool that shares one of the approved names (`file_access_read`, `file_access_ls`, +registered tool that shares one of the approved names (`file_access_read`, `file_access_read_lines`, +`file_access_ls`, `file_access_grep`) would be **silently auto-approved**, bypassing the human approval boundary. Ensure no other tool's name collides with the reserved names an auto-approval rule approves. diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProvider.cs index 9c50cff4a68..b04b4d2d7cf 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProvider.cs @@ -5,6 +5,7 @@ using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -37,6 +38,7 @@ namespace Microsoft.Agents.AI; /// /// file_access_write — Write a file with the given name and content. /// file_access_read — Read the content of a file by name. +/// file_access_read_lines — Read a range of lines from a file by line number. /// file_access_delete — Delete a file by name. /// file_access_ls — List the direct child files and subdirectories of a directory. /// file_access_grep — Recursively search file contents using a regular expression pattern. @@ -44,12 +46,13 @@ namespace Microsoft.Agents.AI; /// file_access_replace_lines — Replace whole lines within a file. /// /// When is set, only the read-only tools -/// (file_access_read, file_access_ls, and file_access_grep) are exposed. +/// (file_access_read, file_access_read_lines, file_access_ls, and +/// file_access_grep) are exposed. /// /// /// By default, all of these tools require approval: each is exposed as an . /// Approval can be disabled per group via -/// (read, ls, and grep) and +/// (read, read_lines, ls, and grep) and /// (write, delete, replace, and replace_lines). /// /// @@ -57,8 +60,8 @@ namespace Microsoft.Agents.AI; /// : /// /// -/// — auto-approves only the read-only tools (read, ls, -/// and grep), while still prompting for the tools that modify the store (write, delete, replace, and replace_lines). +/// — auto-approves only the read-only tools (read, read_lines, +/// ls, and grep), while still prompting for the tools that modify the store (write, delete, replace, and replace_lines). /// /// /// — auto-approves every file access tool, including the tools that modify the store. @@ -82,6 +85,9 @@ public sealed class FileAccessProvider : AIContextProvider, IDisposable /// The name of the tool that reads a file. public const string ReadFileToolName = "file_access_read"; + /// The name of the tool that reads a range of lines from a file. + public const string ReadLinesToolName = "file_access_read_lines"; + /// The name of the tool that deletes a file. public const string DeleteFileToolName = "file_access_delete"; @@ -101,6 +107,7 @@ public sealed class FileAccessProvider : AIContextProvider, IDisposable private static readonly HashSet s_readOnlyToolNames = new(StringComparer.Ordinal) { ReadFileToolName, + ReadLinesToolName, LsToolName, GrepToolName, }; @@ -110,6 +117,7 @@ public sealed class FileAccessProvider : AIContextProvider, IDisposable { WriteToolName, ReadFileToolName, + ReadLinesToolName, DeleteFileToolName, LsToolName, GrepToolName, @@ -129,6 +137,9 @@ These files persist beyond the current session and may be shared across sessions or `file_access_grep` to search file contents recursively across the whole store. - To make small edits to an existing file, prefer `file_access_replace` (substring replacement) or `file_access_replace_lines` (whole-line replacement) over rewriting the whole file. + - To change part of a file, find the line numbers with `file_access_grep`, read the range around them + with `file_access_read_lines`, then edit with `file_access_replace_lines`. Reading the whole file + first is rarely necessary. """; private readonly AgentFileStore _fileStore; @@ -161,7 +172,8 @@ public FileAccessProvider(AgentFileStore fileStore, FileAccessProviderOptions? o /// /// Gets an auto-approval rule that approves the read-only file access tools - /// (, , and ). + /// (, , , + /// and ). /// /// /// @@ -179,6 +191,7 @@ public FileAccessProvider(AgentFileStore fileStore, FileAccessProviderOptions? o /// This rule approves calls to exactly the following tool names: /// /// (file_access_read) + /// (file_access_read_lines) /// (file_access_ls) /// (file_access_grep) /// @@ -213,6 +226,7 @@ public FileAccessProvider(AgentFileStore fileStore, FileAccessProviderOptions? o /// /// (file_access_write) /// (file_access_read) + /// (file_access_read_lines) /// (file_access_delete) /// (file_access_ls) /// (file_access_grep) @@ -292,7 +306,7 @@ private async Task WriteAsync(string fileName, string content, bool over /// The name of the file to read. /// A token to cancel the operation. /// The file content or a not-found message. - [Description("Read the content of a file by name. Returns the file content or a message indicating the file was not found.")] + [Description("Read the content of a file by name. Returns the file content or a message indicating the file was not found. To edit by 1-based line number afterwards, count lines terminated by \\n, \\r\\n, or a lone \\r; each line keeps its own terminator, and content ending in a terminator has no extra empty line after it.")] private async Task ReadAsync(string fileName, CancellationToken cancellationToken = default) { string path = StorePaths.NormalizeRelativePath(fileName); @@ -300,6 +314,46 @@ private async Task ReadAsync(string fileName, CancellationToken cancella return content ?? $"File '{fileName}' not found."; } + /// + /// Read a range of lines from a file, each prefixed with its 1-based line number and a tab. + /// + /// The name of the file to read. + /// The 1-based line number to read from. + /// The 1-based line number to read through, inclusive. When , reads to the end of the file. + /// A token to cancel the operation. + /// The numbered lines, or a not-found message. + /// + /// The line numbers agree with the ones file_access_grep reports, because + /// must number by — + /// the split this method and file_access_replace_lines use. A store overriding it owns that + /// numbering; getting it wrong makes an edit land on a line the caller never saw. + /// + /// + /// Thrown when either bound is not positive, when precedes + /// , or when is past the last line. + /// + [Description("Read part of a file by 1-based inclusive line number; omit endLine to read to the end of the file, and an endLine past the last line is clamped. Each line is prefixed with its number and a tab; everything after that tab is verbatim, including the line's own terminator, so it can be reused as a file_access_replace_lines new_line. Line numbers are 1-based and count lines terminated by \\n, \\r\\n, or a lone \\r, and content ending in a terminator has no extra empty line after it.")] + private async Task ReadLinesAsync(string fileName, int startLine, int? endLine = null, CancellationToken cancellationToken = default) + { + string path = StorePaths.NormalizeRelativePath(fileName); + string? content = await this._fileStore.ReadAsync(path, cancellationToken).ConfigureAwait(false); + if (content is null) + { + return $"File '{fileName}' not found."; + } + + List lines = FileEditor.SliceLines(content, startLine, endLine); + + // Each line keeps its terminator, so it doubles as the row separator. + var builder = new StringBuilder(); + for (int i = 0; i < lines.Count; i++) + { + builder.Append(startLine + i).Append('\t').Append(lines[i]); + } + + return builder.ToString(); + } + /// /// Delete a file by name. /// @@ -381,7 +435,7 @@ private async Task ReplaceAsync(string fileName, string oldString, strin /// The list of 1-based line numbers and their literal replacement text. /// A token to cancel the operation. /// A confirmation message including the number of lines replaced, or a failure message. - [Description("Replace lines in a file. Provide a list of edits, each with a 1-based line_number and a literal new_line (include your own trailing newline); an empty new_line deletes the line, including its line break. Fails on out-of-range or duplicate line numbers.")] + [Description("Replace lines in a file. Provide a list of edits, each with a 1-based line_number and a literal new_line (include your own trailing newline); an empty new_line deletes the line, including its line break. Fails on out-of-range or duplicate line numbers. Line numbers are 1-based and count lines terminated by \\n, \\r\\n, or a lone \\r; each line keeps its own terminator, and content ending in a terminator has no extra empty line after it.")] private async Task ReplaceLinesAsync(string fileName, List edits, CancellationToken cancellationToken = default) { await this._writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); @@ -421,6 +475,7 @@ private async Task ReplaceLinesAsync(string fileName, List - '**' matches across subdirectories, so use \"**/*.md\" to match markdown files at any depth, or \"reports/**\" to restrict the search to the 'reports' subtree. Returns matching results whose file names are paths relative to the store root (usable with file_access_read), along with snippets and matching lines with line numbers. + Line numbers are 1-based and count lines terminated by \n, \r\n, or a lone \r, and content ending in a terminator has no extra empty line after it. """)] private async Task> GrepAsync(string regexPattern, string? globPattern = null, string? directory = null, CancellationToken cancellationToken = default) { @@ -464,6 +519,7 @@ private AITool[] CreateTools() var tools = new List { WrapWithApprovalIfRequired(AIFunctionFactory.Create(this.ReadAsync, new AIFunctionFactoryOptions { Name = ReadFileToolName, SerializerOptions = serializerOptions }), readOnlyRequiresApproval), + WrapWithApprovalIfRequired(AIFunctionFactory.Create(this.ReadLinesAsync, new AIFunctionFactoryOptions { Name = ReadLinesToolName, SerializerOptions = serializerOptions }), readOnlyRequiresApproval), WrapWithApprovalIfRequired(AIFunctionFactory.Create(this.LsAsync, new AIFunctionFactoryOptions { Name = LsToolName, SerializerOptions = serializerOptions }), readOnlyRequiresApproval), WrapWithApprovalIfRequired(AIFunctionFactory.Create(this.GrepAsync, new AIFunctionFactoryOptions { Name = GrepToolName, SerializerOptions = serializerOptions }), readOnlyRequiresApproval), }; diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProviderOptions.cs index 8f8e406e486..c26b4781cec 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileAccess/FileAccessProviderOptions.cs @@ -25,7 +25,8 @@ public sealed class FileAccessProviderOptions /// /// /// When (the default), all tools are exposed. When , - /// only the read-only tools (file_access_read, file_access_ls, and file_access_grep) + /// only the read-only tools (file_access_read, file_access_read_lines, file_access_ls, + /// and file_access_grep) /// are exposed; the tools that modify the store (file_access_write, file_access_delete, /// file_access_replace, and file_access_replace_lines) are hidden. /// @@ -33,8 +34,8 @@ public sealed class FileAccessProviderOptions /// /// Gets or sets a value indicating whether approval is disabled for the read-only file access tools - /// (, , - /// and ). + /// (, , + /// , and ). /// /// /// When (the default), these tools require approval before invocation. diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs index 481089bd2c5..ee91be5cffd 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileMemory/FileMemoryProvider.cs @@ -219,7 +219,7 @@ private async Task WriteAsync(string fileName, string content, string? d /// The name of the file to read. /// A token to cancel the operation. /// The file content or a not-found message. - [Description("Read the content of a memory file by name. Returns the file content or a message indicating the file was not found.")] + [Description("Read the content of a memory file by name. Returns the file content or a message indicating the file was not found. To edit by 1-based line number afterwards, count lines terminated by \\n, \\r\\n, or a lone \\r; each line keeps its own terminator, and content ending in a terminator has no extra empty line after it.")] private async Task ReadAsync(string fileName, CancellationToken cancellationToken = default) { string normalized = StorePaths.NormalizeRelativePath(fileName); @@ -360,7 +360,7 @@ private async Task ReplaceAsync(string fileName, string oldString, strin /// The list of 1-based line numbers and their literal replacement text. /// A token to cancel the operation. /// A confirmation message including the number of lines replaced, or a failure message. - [Description("Replace lines in a memory file. Provide a list of edits, each with a 1-based line_number and a literal new_line (include your own trailing newline); an empty new_line deletes the line, including its line break. Fails on out-of-range or duplicate line numbers.")] + [Description("Replace lines in a memory file. Provide a list of edits, each with a 1-based line_number and a literal new_line (include your own trailing newline); an empty new_line deletes the line, including its line break. Fails on out-of-range or duplicate line numbers. Line numbers are 1-based and count lines terminated by \\n, \\r\\n, or a lone \\r; each line keeps its own terminator, and content ending in a terminator has no extra empty line after it.")] private async Task ReplaceLinesAsync(string fileName, List edits, CancellationToken cancellationToken = default) { string normalized = StorePaths.NormalizeRelativePath(fileName); diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/AgentFileStore.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/AgentFileStore.cs index 4a7feee0517..e275d33a437 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/AgentFileStore.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/AgentFileStore.cs @@ -1,11 +1,14 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.FileSystemGlobbing; using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI; @@ -92,7 +95,209 @@ public abstract class AgentFileStore /// A list of search results. Each result's is the matching file's /// path relative to . /// - public abstract Task> SearchAsync(string directory, string regexPattern, string? globPattern = null, bool recursive = false, CancellationToken cancellationToken = default); + /// + /// + /// Implementers overriding this method must report as a + /// 1-based coordinate into of the same content + /// returns, and should report verbatim, terminator included. + /// produces both correctly and is the recommended way to build results. + /// + /// + /// Numbering against anything else — a different split rule, or content this store does not serve + /// through — is a bug with a silent failure mode: the search looks correct, + /// and the damage appears later when a line edit applies to a line the caller never saw. Cover it + /// with a test that greps and then edits by the reported number. + /// + /// + public virtual async Task> SearchAsync(string directory, string regexPattern, string? globPattern = null, bool recursive = false, CancellationToken cancellationToken = default) + { + // Compile with a match timeout to guard against catastrophic backtracking (ReDoS). + var regex = new Regex(regexPattern, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(5)); + IReadOnlyList names = await this.FindMatchingFilesAsync(directory, regexPattern, globPattern, recursive, cancellationToken).ConfigureAwait(false); + Matcher? matcher = globPattern is not null ? StorePaths.CreateGlobMatcher(globPattern) : null; + var results = new List(); + + foreach (string name in names) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Re-apply the caller's scope: FindMatchingFilesAsync is explicitly allowed to + // over-return, and must not be able to widen what the caller asked for. + if (!StorePaths.MatchesGlob(name, matcher) || + (!recursive && name.IndexOf("/", StringComparison.Ordinal) >= 0)) + { + continue; + } + + string path = string.IsNullOrEmpty(directory) ? name : $"{directory.TrimEnd('/')}/{name}"; + string? content = await this.ReadAsync(path, cancellationToken).ConfigureAwait(false); + if (content is null) + { + continue; // Deleted between enumeration and read. + } + + FileSearchResult? result = ScanContent(name, content, regex); + if (result is not null) + { + results.Add(result); + } + } + + return results; + } + + /// + /// Gets the names of the files that may contain text that matches + /// and where file names may match + /// . + /// + /// + /// + /// This is the hook a store uses to narrow the search to the files worth reading. Semantics are + /// deliberately a superset: returning a file that turns out not to match is harmless, + /// because re-scans every candidate, while omitting one loses the match. + /// A backend with a native search index should override this and push + /// down to it, widening rather than guessing where the dialect + /// cannot express the pattern. + /// + /// + /// The default implementation has no index to narrow with, so it walks + /// and returns every file in scope, leaving + /// to read and scan all of them. Override this when the backing store + /// can answer either question more cheaply than that — a name index for + /// , a content or full-text index for — + /// and return the candidates it finds. That is the whole purpose of the hook: the store does the + /// narrowing it is good at, and the base keeps the scanning and the line numbering. Overriding + /// instead is also supported, but then line numbering is the store's + /// responsibility (see ), and nothing checks it at runtime. + /// + /// + /// The relative directory being searched. Use an empty string for the root. + /// + /// The pattern was called with, as a hint. It is matched + /// case-insensitively, so an index that cannot search that way must widen rather than narrow: + /// returning only case-exact candidates drops matches the caller would have got. + /// + /// + /// The optional glob, matched against each file's path relative to , + /// also case-insensitively. The same rule applies — widen when the backend cannot reproduce it. + /// + /// When only direct children are in scope. + /// A token to cancel the operation. + /// File paths relative to , using forward slashes. + protected virtual async Task> FindMatchingFilesAsync(string directory, string regexPattern, string? globPattern = null, bool recursive = false, CancellationToken cancellationToken = default) + { + _ = regexPattern; // No index to narrow with here; a backend with one overrides this. + var names = new List(); + var pending = new Stack(); + pending.Push(string.Empty); + + while (pending.Count > 0) + { + // Checked here as well as passed down: a store whose ListChildrenAsync ignores the token + // would otherwise let a cancelled walk enumerate the whole hierarchy one listing at a time. + cancellationToken.ThrowIfCancellationRequested(); + + string relativeDir = pending.Pop(); + string target = string.IsNullOrEmpty(relativeDir) + ? directory + : (string.IsNullOrEmpty(directory) ? relativeDir : $"{directory.TrimEnd('/')}/{relativeDir}"); + + foreach (FileStoreEntry entry in await this.ListChildrenAsync(target, cancellationToken).ConfigureAwait(false)) + { + cancellationToken.ThrowIfCancellationRequested(); + + string child = string.IsNullOrEmpty(relativeDir) ? entry.Name : $"{relativeDir}/{entry.Name}"; + if (entry.Type == FileStoreEntry.Directory) + { + if (recursive) + { + pending.Push(child); + } + } + else + { + names.Add(child); + } + } + } + + return names; + } + + /// + /// Splits into the lines this SDK's line numbers address. + /// + /// + /// + /// This is the published definition of a line for the whole file-access surface: the + /// read_lines and replace_lines tools, and every + /// reported by , are coordinates in this list. Each line keeps its + /// terminator (\r\n, \n, or a lone \r), and the final line has none when the + /// content does not end with a newline. + /// + /// + /// A store that overrides must number its matches by this split, + /// otherwise grep and the line editor disagree and an edit lands on the wrong line. The rule is + /// per-SDK: it is not required to match the Python implementation, only to be consistent within + /// this one, because a line number never crosses runtimes. + /// + /// + /// The full text to split. + /// The lines, each with its terminator attached. + public static IReadOnlyList SplitLines(string content) => FileEditor.SplitLinesKeepEnds(Throw.IfNull(content)); + + /// + /// Finds every line of matching , numbered by + /// . + /// + /// + /// This is the numbering primitive uses, published so a store that + /// supplies its own can produce aligned results rather than re-deriving + /// them. Lines are reported verbatim, terminator included; the pattern is matched against the + /// line without its terminator, so an end-anchored pattern behaves the same on CRLF content. + /// + /// The name recorded on the result, relative to the searched directory. + /// The file's full text. + /// A compiled pattern, normally from the same source string passed to . + /// The match metadata, or when no line matches. + public static FileSearchResult? ScanContent(string fileName, string content, Regex regex) + { + _ = Throw.IfNull(fileName); + _ = Throw.IfNull(content); + _ = Throw.IfNull(regex); + + IReadOnlyList lines = SplitLines(content); + var matchingLines = new List(); + string? firstSnippet = null; + int lineStartOffset = 0; + + for (int i = 0; i < lines.Count; i++) + { + // Match over the line's text only, without copying it out of the line. + Match match = regex.Match(lines[i], 0, FileEditor.LineContentLength(lines[i])); + if (match.Success) + { + matchingLines.Add(new FileSearchMatch { LineNumber = i + 1, Line = lines[i] }); + + // Build a context snippet around the first match (+/-50 chars). + if (firstSnippet is null) + { + int charIndex = lineStartOffset + match.Index; + int snippetStart = Math.Max(0, charIndex - 50); + int snippetEnd = Math.Min(content.Length, charIndex + match.Value.Length + 50); + firstSnippet = content.Substring(snippetStart, snippetEnd - snippetStart); + } + } + + // Advance past this line; its terminator is already part of its length. + lineStartOffset += lines[i].Length; + } + + return matchingLines.Count == 0 + ? null + : new FileSearchResult { FileName = fileName, Snippet = firstSnippet!, MatchingLines = matchingLines }; + } /// /// Ensures a directory exists, creating it if necessary. diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileEditor.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileEditor.cs index 32c3ff84783..eea214d1dde 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileEditor.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileEditor.cs @@ -7,7 +7,8 @@ namespace Microsoft.Agents.AI; /// /// Internal helpers shared by and -/// for the replace and replace_lines tools. +/// for the replace, replace_lines, and read_lines tools, and by the file stores +/// for grep. /// internal static class FileEditor { @@ -80,6 +81,20 @@ internal static string ApplyReplaceLines(string content, IReadOnlyList + /// Returns the 1-based inclusive [startLine, endLine] slice of , + /// with each line's terminator kept attached. An past the last line is + /// clamped, and omitting it reads to the end of the content. + /// + /// + /// Thrown when either bound is not positive, when precedes + /// , or when is past the last line. + /// + internal static List SliceLines(string content, int startLine, int? endLine) + { + List lines = SplitLinesKeepEnds(content); + int total = lines.Count; + + // These messages reach the model as the tool's failure text, so they name the arguments as the + // generated schema exposes them (startLine/endLine), not in snake_case. + if (startLine < 1) + { + throw new ArgumentException($"startLine must be a positive integer, got {startLine}."); + } + + if (endLine is < 1) + { + throw new ArgumentException($"endLine must be a positive integer, got {endLine}."); + } + + if (endLine < startLine) + { + throw new ArgumentException($"endLine ({endLine}) must not be less than startLine ({startLine})."); + } + + if (startLine > total) + { + throw new ArgumentException($"startLine {startLine} is out of range (file has {total} lines)."); + } + + // Clamping end_line rather than failing keeps "read from here to the end" a single call. + int lastLine = endLine is null ? total : Math.Min(endLine.Value, total); + return lines.GetRange(startLine - 1, lastLine - startLine + 1); + } + + /// + /// Returns without its trailing \r\n, \n or lone \r. + /// + internal static string TrimLineTerminator(string line) => line.Substring(0, LineContentLength(line)); + + /// + /// Returns the length of up to but excluding the \r\n, \n, or + /// lone \r that terminates it, so search patterns are matched against a line's text rather + /// than its line break. + /// + /// + /// Leaving any part of the terminator in range would make an end-anchored pattern such as + /// match$ fail on a CRLF or lone-CR line whose text is exactly match. This returns a + /// length rather than a trimmed string because the callers scan every line before knowing which ones + /// match, and copying each one would duplicate nearly the whole file on every search. + /// + internal static int LineContentLength(string line) + { + if (line.EndsWith("\r\n", StringComparison.Ordinal)) + { + return line.Length - 2; + } + + return line.EndsWith("\n", StringComparison.Ordinal) || line.EndsWith("\r", StringComparison.Ordinal) + ? line.Length - 1 + : line.Length; + } + private static int CountOccurrences(string content, string value) { int count = 0; @@ -109,7 +193,13 @@ private static int CountOccurrences(string content, string value) /// Splits content into lines, keeping each line's trailing newline (\r\n, \n, or a lone /// \r) attached. The final line has no terminator when the content does not end with a newline. /// - private static List SplitLinesKeepEnds(string content) + /// + /// This is the single definition of a "line" for the line-edit tools, so the line numbers reported by + /// grep address the same lines that replace_lines edits. A store supplying its own + /// is expected to number by this split; nothing enforces that + /// at runtime, so an implementation that numbers differently edits the wrong line silently. + /// + internal static List SplitLinesKeepEnds(string content) { var lines = new List(); int start = 0; diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileLineEdit.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileLineEdit.cs index 5100715bdf3..824d388ed75 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileLineEdit.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileLineEdit.cs @@ -28,4 +28,15 @@ public sealed class FileLineEdit [JsonPropertyName("new_line")] [Description("Literal replacement text for the line, including any trailing newline you want to keep (the editor does not add one). Set to an empty string to delete the line entirely, including its line break.")] public string NewLine { get; set; } = string.Empty; + + /// + /// Gets or sets the text the caller believes is currently on that line. When set, the edit is + /// rejected unless it matches, which catches an out-of-date line number or a file that changed + /// since it was read. This is the line's own text: a numbered read prefixes each line with its + /// number and a tab, and that prefix is not part of the line. The trailing line terminator is + /// ignored in the comparison. + /// + [JsonPropertyName("expected_line")] + [Description("Optional: the text you believe is currently on that line, as reported by grep. Give the line's own text only: a numbered read prefixes each line with its number and a tab, and that prefix is not part of the line. When supplied, the edit is rejected unless it matches, which catches an out-of-date line number or a file that changed since you looked. The trailing newline is ignored in the comparison.")] + public string? ExpectedLine { get; set; } } diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSearchMatch.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSearchMatch.cs index 0bf2d102d3a..a65cd7d217d 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSearchMatch.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSearchMatch.cs @@ -19,8 +19,14 @@ public sealed class FileSearchMatch public int LineNumber { get; set; } /// - /// Gets or sets the content of the matching line. + /// Gets or sets the matching line, verbatim. /// + /// + /// Implementers should report the line exactly as it appears in the file, keeping its own terminator + /// (\r\n, \n, or a lone \r), except on a final line that the content does not + /// terminate. Together with addressing the same lines the line-edit tools use, + /// that makes the value reusable as a literal replacement line without re-reading the file. + /// [JsonPropertyName("line")] public string Line { get; set; } = string.Empty; } diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSystemAgentFileStore.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSystemAgentFileStore.cs index 8f3d171c94d..0cc2f6f9c17 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSystemAgentFileStore.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/FileSystemAgentFileStore.cs @@ -203,41 +203,12 @@ public override async Task> SearchAsync( } #endif - // Search each line for regex matches, tracking line numbers and building a snippet. - string[] lines = fileContent.Split('\n'); - var matchingLines = new List(); - string? firstSnippet = null; - int lineStartOffset = 0; - - for (int i = 0; i < lines.Length; i++) - { - Match match = regex.Match(lines[i]); - if (match.Success) - { - matchingLines.Add(new FileSearchMatch { LineNumber = i + 1, Line = lines[i].TrimEnd('\r') }); - - // Build a context snippet around the first match (±50 chars). - if (firstSnippet is null) - { - int charIndex = lineStartOffset + match.Index; - int snippetStart = Math.Max(0, charIndex - 50); - int snippetEnd = Math.Min(fileContent.Length, charIndex + match.Value.Length + 50); - firstSnippet = fileContent.Substring(snippetStart, snippetEnd - snippetStart); - } - } - - // Advance the offset past this line (including the '\n' separator). - lineStartOffset += lines[i].Length + 1; - } - - if (matchingLines.Count > 0) + // Number the lines through the base class's published primitive, so this store + // and the line editor cannot drift apart. + FileSearchResult? result = ScanContent(relativeName, fileContent, regex); + if (result is not null) { - results.Add(new FileSearchResult - { - FileName = relativeName, - Snippet = firstSnippet!, - MatchingLines = matchingLines, - }); + results.Add(result); } } diff --git a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/InMemoryAgentFileStore.cs b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/InMemoryAgentFileStore.cs index 62dfc020cb4..3c532320c74 100644 --- a/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/InMemoryAgentFileStore.cs +++ b/dotnet/src/Microsoft.Agents.AI/Harness/FileStore/InMemoryAgentFileStore.cs @@ -141,42 +141,12 @@ public override Task> SearchAsync(string directo continue; } - // Search each line for regex matches, tracking line numbers and building a snippet. - string fileContent = kvp.Value; - string[] lines = fileContent.Split('\n'); - var matchingLines = new List(); - string? firstSnippet = null; - int lineStartOffset = 0; - - for (int i = 0; i < lines.Length; i++) + // Number the lines through the base class's published primitive, so this store + // and the line editor cannot drift apart. + FileSearchResult? result = ScanContent(relativeName, kvp.Value, regex); + if (result is not null) { - Match match = regex.Match(lines[i]); - if (match.Success) - { - matchingLines.Add(new FileSearchMatch { LineNumber = i + 1, Line = lines[i].TrimEnd('\r') }); - - // Build a context snippet around the first match (±50 chars). - if (firstSnippet is null) - { - int charIndex = lineStartOffset + match.Index; - int snippetStart = Math.Max(0, charIndex - 50); - int snippetEnd = Math.Min(fileContent.Length, charIndex + match.Value.Length + 50); - firstSnippet = fileContent.Substring(snippetStart, snippetEnd - snippetStart); - } - } - - // Advance the offset past this line (including the '\n' separator). - lineStartOffset += lines[i].Length + 1; - } - - if (matchingLines.Count > 0) - { - results.Add(new FileSearchResult - { - FileName = relativeName, - Snippet = firstSnippet!, - MatchingLines = matchingLines, - }); + results.Add(result); } } diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt index cc15ea45814..31d026ce287 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -1,3 +1,11 @@ #nullable enable +*REMOVED*[MAAI001]abstract Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.get -> System.TimeSpan [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.set -> void +[MAAI001]Microsoft.Agents.AI.FileLineEdit.ExpectedLine.get -> string? +[MAAI001]Microsoft.Agents.AI.FileLineEdit.ExpectedLine.set -> void +[MAAI001]const Microsoft.Agents.AI.FileAccessProvider.ReadLinesToolName = "file_access_read_lines" -> string! +[MAAI001]static Microsoft.Agents.AI.AgentFileStore.ScanContent(string! fileName, string! content, System.Text.RegularExpressions.Regex! regex) -> Microsoft.Agents.AI.FileSearchResult? +[MAAI001]static Microsoft.Agents.AI.AgentFileStore.SplitLines(string! content) -> System.Collections.Generic.IReadOnlyList! +[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.FindMatchingFilesAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt index cc15ea45814..31d026ce287 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -1,3 +1,11 @@ #nullable enable +*REMOVED*[MAAI001]abstract Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.get -> System.TimeSpan [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.set -> void +[MAAI001]Microsoft.Agents.AI.FileLineEdit.ExpectedLine.get -> string? +[MAAI001]Microsoft.Agents.AI.FileLineEdit.ExpectedLine.set -> void +[MAAI001]const Microsoft.Agents.AI.FileAccessProvider.ReadLinesToolName = "file_access_read_lines" -> string! +[MAAI001]static Microsoft.Agents.AI.AgentFileStore.ScanContent(string! fileName, string! content, System.Text.RegularExpressions.Regex! regex) -> Microsoft.Agents.AI.FileSearchResult? +[MAAI001]static Microsoft.Agents.AI.AgentFileStore.SplitLines(string! content) -> System.Collections.Generic.IReadOnlyList! +[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.FindMatchingFilesAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt index cc15ea45814..31d026ce287 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -1,3 +1,11 @@ #nullable enable +*REMOVED*[MAAI001]abstract Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.get -> System.TimeSpan [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.set -> void +[MAAI001]Microsoft.Agents.AI.FileLineEdit.ExpectedLine.get -> string? +[MAAI001]Microsoft.Agents.AI.FileLineEdit.ExpectedLine.set -> void +[MAAI001]const Microsoft.Agents.AI.FileAccessProvider.ReadLinesToolName = "file_access_read_lines" -> string! +[MAAI001]static Microsoft.Agents.AI.AgentFileStore.ScanContent(string! fileName, string! content, System.Text.RegularExpressions.Regex! regex) -> Microsoft.Agents.AI.FileSearchResult? +[MAAI001]static Microsoft.Agents.AI.AgentFileStore.SplitLines(string! content) -> System.Collections.Generic.IReadOnlyList! +[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.FindMatchingFilesAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt index cc15ea45814..31d026ce287 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt @@ -1,3 +1,11 @@ #nullable enable +*REMOVED*[MAAI001]abstract Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.get -> System.TimeSpan [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.set -> void +[MAAI001]Microsoft.Agents.AI.FileLineEdit.ExpectedLine.get -> string? +[MAAI001]Microsoft.Agents.AI.FileLineEdit.ExpectedLine.set -> void +[MAAI001]const Microsoft.Agents.AI.FileAccessProvider.ReadLinesToolName = "file_access_read_lines" -> string! +[MAAI001]static Microsoft.Agents.AI.AgentFileStore.ScanContent(string! fileName, string! content, System.Text.RegularExpressions.Regex! regex) -> Microsoft.Agents.AI.FileSearchResult? +[MAAI001]static Microsoft.Agents.AI.AgentFileStore.SplitLines(string! content) -> System.Collections.Generic.IReadOnlyList! +[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.FindMatchingFilesAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index cc15ea45814..31d026ce287 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -1,3 +1,11 @@ #nullable enable +*REMOVED*[MAAI001]abstract Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.get -> System.TimeSpan [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.set -> void +[MAAI001]Microsoft.Agents.AI.FileLineEdit.ExpectedLine.get -> string? +[MAAI001]Microsoft.Agents.AI.FileLineEdit.ExpectedLine.set -> void +[MAAI001]const Microsoft.Agents.AI.FileAccessProvider.ReadLinesToolName = "file_access_read_lines" -> string! +[MAAI001]static Microsoft.Agents.AI.AgentFileStore.ScanContent(string! fileName, string! content, System.Text.RegularExpressions.Regex! regex) -> Microsoft.Agents.AI.FileSearchResult? +[MAAI001]static Microsoft.Agents.AI.AgentFileStore.SplitLines(string! content) -> System.Collections.Generic.IReadOnlyList! +[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.FindMatchingFilesAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! diff --git a/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentTests.cs index b01af37e0fe..feebac05f85 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Harness.UnitTests/HarnessAgentTests.cs @@ -1386,6 +1386,7 @@ public async Task FileAccessProvider_UsesProvidedOptionsAsync() // DisableWriteTools = true => only the read-only tools are exposed. Assert.Contains(FileAccessProvider.ReadFileToolName, toolNames); + Assert.Contains(FileAccessProvider.ReadLinesToolName, toolNames); Assert.Contains(FileAccessProvider.LsToolName, toolNames); Assert.Contains(FileAccessProvider.GrepToolName, toolNames); Assert.DoesNotContain(FileAccessProvider.WriteToolName, toolNames); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileAccess/FileAccessProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileAccess/FileAccessProviderTests.cs index e8797a4bc9b..eb0cd8d2521 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileAccess/FileAccessProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileAccess/FileAccessProviderTests.cs @@ -43,8 +43,8 @@ public async Task ProvideAIContextAsync_ReturnsToolsAsync() // Arrange var tools = await CreateToolsAsync(); - // Assert — 7 tools: Read, Ls, Grep, Write, Delete, Replace, ReplaceLines - Assert.Equal(7, tools.Count()); + // Assert — 8 tools: Read, ReadLines, Ls, Grep, Write, Delete, Replace, ReplaceLines + Assert.Equal(8, tools.Count()); } #endregion @@ -58,7 +58,7 @@ public async Task ProvideAIContextAsync_AllToolsRequireApprovalAsync() var tools = await CreateToolsAsync(); // Assert — every tool is wrapped so that it always requires approval. - Assert.Equal(7, tools.Count()); + Assert.Equal(8, tools.Count()); Assert.All(tools, tool => Assert.IsType(tool)); } @@ -70,6 +70,7 @@ public async Task DisableReadOnlyToolApproval_ReadOnlyToolsNotWrappedAsync() // Assert — read-only tools are bare functions; store-modifying tools still require approval. AssertRequiresApproval(tools, FileAccessProvider.ReadFileToolName, expected: false); + AssertRequiresApproval(tools, FileAccessProvider.ReadLinesToolName, expected: false); AssertRequiresApproval(tools, FileAccessProvider.LsToolName, expected: false); AssertRequiresApproval(tools, FileAccessProvider.GrepToolName, expected: false); AssertRequiresApproval(tools, FileAccessProvider.WriteToolName, expected: true); @@ -86,6 +87,7 @@ public async Task DisableWriteToolApproval_WriteToolsNotWrappedAsync() // Assert — store-modifying tools are bare functions; read-only tools still require approval. AssertRequiresApproval(tools, FileAccessProvider.ReadFileToolName, expected: true); + AssertRequiresApproval(tools, FileAccessProvider.ReadLinesToolName, expected: true); AssertRequiresApproval(tools, FileAccessProvider.LsToolName, expected: true); AssertRequiresApproval(tools, FileAccessProvider.GrepToolName, expected: true); AssertRequiresApproval(tools, FileAccessProvider.WriteToolName, expected: false); @@ -105,7 +107,7 @@ public async Task DisableBothToolApprovals_NoToolsWrappedAsync() })).ToList(); // Assert — no tool requires approval. - Assert.Equal(7, tools.Count); + Assert.Equal(8, tools.Count); Assert.DoesNotContain(tools, tool => tool is ApprovalRequiredAIFunction); } @@ -117,6 +119,7 @@ private static void AssertRequiresApproval(IEnumerable tools, string too [Theory] [InlineData(FileAccessProvider.ReadFileToolName, true)] + [InlineData(FileAccessProvider.ReadLinesToolName, true)] [InlineData(FileAccessProvider.LsToolName, true)] [InlineData(FileAccessProvider.GrepToolName, true)] [InlineData(FileAccessProvider.WriteToolName, false)] @@ -138,6 +141,7 @@ public async Task ReadOnlyToolsAutoApprovalRule_ApprovesOnlyReadOnlyToolsAsync(s [Theory] [InlineData(FileAccessProvider.ReadFileToolName, true)] + [InlineData(FileAccessProvider.ReadLinesToolName, true)] [InlineData(FileAccessProvider.LsToolName, true)] [InlineData(FileAccessProvider.GrepToolName, true)] [InlineData(FileAccessProvider.WriteToolName, true)] @@ -375,6 +379,174 @@ public async Task ReadFile_NonExistent_ReturnsNotFoundMessageAsync() #endregion + #region ReadLines Tests + + [Fact] + public async Task ReadLines_ReturnsNumberedInclusiveRangeAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteAsync("notes.md", "one\ntwo\nthree\nfour\n"); + var tools = await CreateToolsAsync(store); + var readLines = GetTool(tools, "file_access_read_lines"); + + // Act + var result = await InvokeToolAsync(readLines, new AIFunctionArguments + { + ["fileName"] = "notes.md", + ["startLine"] = 2, + ["endLine"] = 3, + }); + + // Assert — each line keeps its terminator, which doubles as the row separator. + var text = Assert.IsType(result).GetString(); + Assert.Equal("2\ttwo\n3\tthree\n", text); + } + + [Fact] + public async Task ReadLines_OmittedEndLine_ReadsToEndOfFileAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteAsync("notes.md", "one\ntwo\nthree"); + var tools = await CreateToolsAsync(store); + var readLines = GetTool(tools, "file_access_read_lines"); + + // Act + var result = await InvokeToolAsync(readLines, new AIFunctionArguments + { + ["fileName"] = "notes.md", + ["startLine"] = 2, + }); + + // Assert — the last line has no terminator, so the output ends without one. + var text = Assert.IsType(result).GetString(); + Assert.Equal("2\ttwo\n3\tthree", text); + } + + [Fact] + public async Task ReadLines_EndLinePastLastLine_IsClampedAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteAsync("notes.md", "one\ntwo\n"); + var tools = await CreateToolsAsync(store); + var readLines = GetTool(tools, "file_access_read_lines"); + + // Act + var result = await InvokeToolAsync(readLines, new AIFunctionArguments + { + ["fileName"] = "notes.md", + ["startLine"] = 1, + ["endLine"] = 99, + }); + + // Assert — clamping, not an error. + var text = Assert.IsType(result).GetString(); + Assert.Equal("1\tone\n2\ttwo\n", text); + } + + [Fact] + public async Task ReadLines_PreservesCrlfTerminatorsAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteAsync("notes.md", "alpha\r\nbeta\r\n"); + var tools = await CreateToolsAsync(store); + var readLines = GetTool(tools, "file_access_read_lines"); + + // Act + var result = await InvokeToolAsync(readLines, new AIFunctionArguments + { + ["fileName"] = "notes.md", + ["startLine"] = 1, + ["endLine"] = 1, + }); + + // Assert — the line's own terminator is reported, so no detection step is needed. + var text = Assert.IsType(result).GetString(); + Assert.Equal("1\talpha\r\n", text); + } + + [Fact] + public async Task ReadLines_NonExistent_ReturnsNotFoundMessageAsync() + { + // Arrange + var tools = await CreateToolsAsync(); + var readLines = GetTool(tools, "file_access_read_lines"); + + // Act + var result = await InvokeToolAsync(readLines, new AIFunctionArguments + { + ["fileName"] = "nonexistent.md", + ["startLine"] = 1, + }); + + // Assert — same shape as file_access_read. + var text = Assert.IsType(result).GetString(); + Assert.Contains("not found", text); + } + + [Fact] + public async Task ReadLines_StartLinePastLastLine_ThrowsAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteAsync("notes.md", "one\ntwo\n"); + var tools = await CreateToolsAsync(store); + var readLines = GetTool(tools, "file_access_read_lines"); + + // Act & Assert — exception bubbles, as it does for replace_lines. + await Assert.ThrowsAsync(async () => + await InvokeToolAsync(readLines, new AIFunctionArguments + { + ["fileName"] = "notes.md", + ["startLine"] = 3, + })); + } + + [Fact] + public async Task ReadLines_RoundTripsAGrepMatchIntoReplaceLinesAsync() + { + // Arrange — a CRLF file with a trailing newline, the case where the terminator used to be lost. + var store = new InMemoryAgentFileStore(); + await store.WriteAsync("notes.md", "alpha\r\nbeta needle\r\ngamma\r\n"); + var tools = await CreateToolsAsync(store); + + // Act — grep for the line, read that line number back, then feed the result to replace_lines. + var grepResult = await InvokeToolAsync(GetTool(tools, "file_access_grep"), new AIFunctionArguments + { + ["regexPattern"] = "needle", + }); + JsonElement match = Assert.IsType(grepResult).EnumerateArray().Single() + .GetProperty("matchingLines").EnumerateArray().Single(); + int lineNumber = match.GetProperty("lineNumber").GetInt32(); + + var readResult = await InvokeToolAsync(GetTool(tools, "file_access_read_lines"), new AIFunctionArguments + { + ["fileName"] = "notes.md", + ["startLine"] = lineNumber, + ["endLine"] = lineNumber, + }); + string shown = Assert.IsType(readResult).GetString()!; + + // Everything after the number and tab is the line verbatim, so it is already a valid new_line. + string line = shown.Substring(shown.IndexOf('\t') + 1); + await InvokeToolAsync(GetTool(tools, "file_access_replace_lines"), new AIFunctionArguments + { + ["fileName"] = "notes.md", + ["edits"] = new List { new() { LineNumber = lineNumber, NewLine = line.ToUpperInvariant() } }, + }); + + // Assert — grep, read_lines and replace_lines agree on line 2, and the CRLF survives. + Assert.Equal(2, lineNumber); + Assert.Equal("beta needle\r\n", match.GetProperty("line").GetString()); + Assert.Equal("2\tbeta needle\r\n", shown); + Assert.Equal("alpha\r\nBETA NEEDLE\r\ngamma\r\n", await store.ReadAsync("notes.md")); + } + + #endregion + #region DeleteFile Tests [Fact] @@ -874,8 +1046,9 @@ public async Task Options_DisableWriteTools_OnlyExposesReadOnlyToolsAsync() var names = result.Tools!.OfType().Select(t => t.Name).ToList(); // Assert — only read-only tools are exposed. - Assert.Equal(3, names.Count); + Assert.Equal(4, names.Count); Assert.Contains(FileAccessProvider.ReadFileToolName, names); + Assert.Contains(FileAccessProvider.ReadLinesToolName, names); Assert.Contains(FileAccessProvider.LsToolName, names); Assert.Contains(FileAccessProvider.GrepToolName, names); Assert.DoesNotContain(FileAccessProvider.WriteToolName, names); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs index 77dc5c7c4b7..d6002c75665 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileMemory/FileMemoryProviderTests.cs @@ -975,12 +975,12 @@ public async Task Write_WithDescription_ReturnsWrittenWithDescriptionAsync() #region Helper Methods - private static FileMemoryProvider CreateProvider(InMemoryAgentFileStore? store = null, Func? stateInitializer = null) + private static FileMemoryProvider CreateProvider(AgentFileStore? store = null, Func? stateInitializer = null) { return new FileMemoryProvider(store ?? new InMemoryAgentFileStore(), stateInitializer); } - private static async Task<(IEnumerable Tools, FileMemoryState State, AgentSession Session)> CreateToolsAsync(InMemoryAgentFileStore? store = null, Func? stateInitializer = null) + private static async Task<(IEnumerable Tools, FileMemoryState State, AgentSession Session)> CreateToolsAsync(AgentFileStore? store = null, Func? stateInitializer = null) { var provider = CreateProvider(store, stateInitializer); var agent = new Mock().Object; diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/AgentFileStoreContractTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/AgentFileStoreContractTests.cs new file mode 100644 index 00000000000..027bdd48eb6 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/AgentFileStoreContractTests.cs @@ -0,0 +1,257 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.UnitTests.Harness.FileMemory; + +/// +/// Unit tests for the line-numbering contract on : the published split, +/// the numbering primitive, the narrowing hook, the base +/// built on top of them, and the guards that keep a store's line numbers honest. +/// +public class AgentFileStoreContractTests +{ + private const string Needle = "keep me"; + + /// + /// A store implementing only the mandatory members. Before the contract this could not exist: + /// SearchAsync was abstract. It now inherits the base implementation and must produce line + /// numbers that address the same lines the editor edits. + /// + private class ContentOnlyStore : AgentFileStore + { + public Dictionary Files { get; } = []; + + public override Task WriteAsync(string path, string content, CancellationToken cancellationToken = default) + { + this.Files[path] = content; + return Task.CompletedTask; + } + + public override Task ReadAsync(string path, CancellationToken cancellationToken = default) + => Task.FromResult(this.Files.TryGetValue(path, out string? value) ? value : null); + + public override Task DeleteAsync(string path, CancellationToken cancellationToken = default) + => Task.FromResult(this.Files.Remove(path)); + + public override Task> ListChildrenAsync(string directory, CancellationToken cancellationToken = default) + { + string prefix = string.IsNullOrEmpty(directory) ? string.Empty : directory + "/"; + var seen = new Dictionary(); + foreach (string path in this.Files.Keys) + { + if (!path.StartsWith(prefix, StringComparison.Ordinal)) + { + continue; + } + + string tail = path.Substring(prefix.Length); + int slash = tail.IndexOf('/'); + seen[slash < 0 ? tail : tail.Substring(0, slash)] = slash < 0 ? FileStoreEntry.File : FileStoreEntry.Directory; + } + + return Task.FromResult>( + seen.Select(kvp => new FileStoreEntry(kvp.Key, kvp.Value)).ToList()); + } + + public override Task FileExistsAsync(string path, CancellationToken cancellationToken = default) + => Task.FromResult(this.Files.ContainsKey(path)); + + public override Task CreateDirectoryAsync(string path, CancellationToken cancellationToken = default) + => Task.CompletedTask; + } + + /// A store whose narrowing hook consults an index instead of listing everything. + private sealed class NarrowingStore : ContentOnlyStore + { + public HashSet Indexed { get; } = []; + + protected override Task> FindMatchingFilesAsync(string directory, string regexPattern, string? globPattern = null, bool recursive = false, CancellationToken cancellationToken = default) + => Task.FromResult>(this.Indexed.OrderBy(x => x, StringComparer.Ordinal).ToList()); + } + + [Fact] + public void SplitLines_PublishesTheEditorsRule() + { + foreach (string content in new[] { "a\nb\n", "a\rb", "", "x", "a\r\nb\r\n" }) + { + // Assert + Assert.Equal(FileEditor.SplitLinesKeepEnds(content), AgentFileStore.SplitLines(content)); + } + } + + [Fact] + public void ScanContent_NumbersBySplitLines() + { + // Act + FileSearchResult? result = AgentFileStore.ScanContent("f.txt", "alpha\r\nbeta match\r\ngamma\r\n", new Regex("match", RegexOptions.IgnoreCase)); + + // Assert + Assert.NotNull(result); + FileSearchMatch match = result!.MatchingLines[0]; + Assert.Equal(AgentFileStore.SplitLines("alpha\r\nbeta match\r\ngamma\r\n")[match.LineNumber - 1], match.Line); + Assert.Equal("beta match\r\n", match.Line); + } + + [Fact] + public async Task StoreWithoutSearch_UsesBasePathAndStaysAlignedAsync() + { + // Arrange + var store = new ContentOnlyStore(); + const string Raw = "alpha\r\nDEBUG = 1\r\nkeep me\r\nDEBUG = 2\r\n"; + await store.WriteAsync("cfg.txt", Raw); + + // Act + IReadOnlyList results = await store.SearchAsync(string.Empty, Needle, recursive: true); + + // Assert: the number grep reports addresses the line the editor will touch. + FileSearchMatch match = Assert.Single(Assert.Single(results).MatchingLines); + Assert.Equal(3, match.LineNumber); + Assert.Equal("keep me\r\n", match.Line); + Assert.Equal(match.Line, FileEditor.SliceLines(Raw, match.LineNumber, match.LineNumber)[0]); + } + + [Fact] + public async Task BaseSearch_ReappliesGlobAndRecursionWhenAStoreOverReturnsAsync() + { + // Arrange: the hook returns everything, ignoring both the glob and the recursion flag. + var store = new NarrowingStore(); + await store.WriteAsync("top.md", Needle); + await store.WriteAsync("notes.txt", Needle); + await store.WriteAsync("nested/deep.md", Needle); + foreach (string name in store.Files.Keys) + { + store.Indexed.Add(name); + } + + // Act + IReadOnlyList topLevelMarkdown = await store.SearchAsync(string.Empty, Needle, "*.md", recursive: true); + IReadOnlyList allMarkdown = await store.SearchAsync(string.Empty, Needle, "**/*.md", recursive: true); + IReadOnlyList shallow = await store.SearchAsync(string.Empty, Needle, recursive: false); + + // Assert: the glob is re-applied, using this SDK's Matcher semantics where "*" does not + // cross "/" (unlike the Python side's fnmatch, where it does). + Assert.Equal(["top.md"], topLevelMarkdown.Select(r => r.FileName)); + Assert.Equal(["nested/deep.md", "top.md"], allMarkdown.Select(r => r.FileName).OrderBy(x => x, StringComparer.Ordinal)); + + // And the non-recursive rule still excludes the nested file. + Assert.Equal(["notes.txt", "top.md"], shallow.Select(r => r.FileName).OrderBy(x => x, StringComparer.Ordinal)); + } + + [Fact] + public async Task BaseSearch_NarrowsThroughTheHookAsync() + { + // Arrange: three files match, but only one is indexed. Under-returning breaks the hook's + // contract; it is done here because nothing else proves the hook chose what got read. + var store = new NarrowingStore(); + for (int i = 0; i < 3; i++) + { + await store.WriteAsync($"f{i}.txt", $"alpha\n{Needle}\n"); + } + + store.Indexed.Add("f1.txt"); + + // Act + IReadOnlyList results = await store.SearchAsync(string.Empty, Needle, recursive: true); + + // Assert: narrowing decides what is read; the base still numbers it. + Assert.Equal("f1.txt", Assert.Single(results).FileName); + Assert.Equal(2, results[0].MatchingLines[0].LineNumber); + } + + [Fact] + public void ApplyReplaceLines_ExpectedLineMatching_AppliesTheEdit() + { + // Act + string result = FileEditor.ApplyReplaceLines( + "one\ntwo\nthree\n", + [new FileLineEdit { LineNumber = 2, NewLine = "TWO\n", ExpectedLine = "two" }]); + + // Assert + Assert.Equal("one\nTWO\nthree\n", result); + } + + [Fact] + public void ApplyReplaceLines_ExpectedLineDiffering_Throws() + { + // Act + Assert: a stale or mis-numbered edit is refused rather than applied. + ArgumentException error = Assert.Throws(() => + FileEditor.ApplyReplaceLines( + "one\ntwo\nthree\n", + [new FileLineEdit { LineNumber = 3, NewLine = "X\n", ExpectedLine = "two" }])); + + Assert.Contains("does not match the expected text", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void ApplyReplaceLines_ExpectedLineIgnoresTheTerminator() + { + // Act: a line fed straight back from grep still carries its terminator. + string result = FileEditor.ApplyReplaceLines( + "alpha\r\nbeta\r\n", + [new FileLineEdit { LineNumber = 2, NewLine = "BETA\r\n", ExpectedLine = "beta\r\n" }]); + + // Assert + Assert.Equal("alpha\r\nBETA\r\n", result); + } + + [Fact] + public void ApplyReplaceLines_WithoutExpectedLine_IsUnchanged() + { + // Act: the guard is opt-in. + string result = FileEditor.ApplyReplaceLines("one\ntwo\n", [new FileLineEdit { LineNumber = 1, NewLine = "ONE\n" }]); + + // Assert + Assert.Equal("ONE\ntwo\n", result); + } + + /// Lists children without observing the token, and counts how often it is asked. + private sealed class CountingListStore : ContentOnlyStore + { + public int Listings { get; private set; } + + public override Task> ListChildrenAsync(string directory, CancellationToken cancellationToken = default) + { + this.Listings++; + return base.ListChildrenAsync(directory, CancellationToken.None); + } + } + + [Fact] + public async Task BaseSearch_StopsWalkingWhenCancelledEvenIfTheStoreIgnoresTheTokenAsync() + { + // Arrange — twenty directories to walk, and a store that takes the token and never reads it, + // which is the shape that leaves a cancelled walk enumerating the whole hierarchy. + var store = new CountingListStore(); + for (int index = 0; index < 20; index++) + { + await store.WriteAsync($"dir{index}/f.txt", Needle); + } + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act + await Assert.ThrowsAnyAsync( + () => store.SearchAsync(string.Empty, Needle, recursive: true, cancellationToken: cts.Token)); + + // Assert — the walk must stop at once. Throwing alone proves nothing here, because + // SearchAsync checks the token itself once FindMatchingFilesAsync has already returned. + Assert.Equal(0, store.Listings); + } + + [Fact] + public void ScanContent_NullFileName_Throws() + { + // Arrange — a pattern that matches, since a non-matching scan returns null and hides the problem. + var regex = new Regex(Needle, RegexOptions.IgnoreCase); + + // Act & Assert + Assert.Throws(() => AgentFileStore.ScanContent(null!, Needle, regex)); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/FileEditorTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/FileEditorTests.cs index 505e761ca9d..99b4f37eae8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/FileEditorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/FileEditorTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Text.RegularExpressions; namespace Microsoft.Agents.AI.UnitTests.Harness.FileMemory; @@ -178,4 +179,130 @@ public void ApplyReplaceLines_EmbeddedNewLine_ExpandsIntoMultipleLines() } #endregion + + #region SplitLinesKeepEnds + + [Theory] + [InlineData("a\nb\nc", new[] { "a\n", "b\n", "c" })] + [InlineData("a\nb\n", new[] { "a\n", "b\n" })] + [InlineData("a\r\nb\r\n", new[] { "a\r\n", "b\r\n" })] + [InlineData("a\rb\rc", new[] { "a\r", "b\r", "c" })] + [InlineData("a\r\nb\nc\r", new[] { "a\r\n", "b\n", "c\r" })] + [InlineData("single", new[] { "single" })] + [InlineData("", new string[0])] + public void SplitLinesKeepEnds_KeepsEachLinesOwnTerminator(string content, string[] expected) + { + // Act + List lines = FileEditor.SplitLinesKeepEnds(content); + + // Assert + Assert.Equal(expected, lines); + } + + [Fact] + public void SplitLinesKeepEnds_ConcatenationRoundTripsTheContent() + { + // Arrange — mixed terminators, the case a whole-file read would otherwise be needed to detect. + const string Content = "alpha\r\nbeta\ngamma\rdelta"; + + // Act + List lines = FileEditor.SplitLinesKeepEnds(Content); + + // Assert — nothing is lost or added, which is what makes a reported line reusable verbatim. + Assert.Equal(Content, string.Concat(lines)); + } + + [Theory] + [InlineData("match\r\n", "match")] + [InlineData("match\n", "match")] + [InlineData("match\r", "match")] + [InlineData("match", "match")] + [InlineData("", "")] + [InlineData("a\rb\n", "a\rb")] + public void LineContentLength_ExcludesOnlyTheTrailingTerminator(string line, string expected) + { + // Act + int length = FileEditor.LineContentLength(line); + + // Assert — the length delimits exactly the line's text, which is the range searches match over. + Assert.Equal(expected.Length, length); + Assert.Equal(expected, line.Substring(0, length)); + } + + [Theory] + [InlineData("beta match\r\n")] + [InlineData("beta match\n")] + [InlineData("beta match\r")] + [InlineData("beta match")] + public void LineContentLength_BoundsAnEndAnchoredMatch(string line) + { + // Arrange — the callers match over a range instead of a trimmed copy, so '$' has to anchor at + // the returned length rather than at the end of the string. + var regex = new Regex("match$", RegexOptions.IgnoreCase); + + // Act + Match match = regex.Match(line, 0, FileEditor.LineContentLength(line)); + + // Assert + Assert.True(match.Success); + Assert.Equal(5, match.Index); + } + + #endregion + + #region SliceLines + + [Fact] + public void SliceLines_ReturnsInclusiveRangeWithTerminators() + { + // Act + List lines = FileEditor.SliceLines("one\ntwo\nthree\nfour\n", 2, 3); + + // Assert + Assert.Equal(2, lines.Count); + Assert.Equal("two\nthree\n", string.Concat(lines)); + } + + [Fact] + public void SliceLines_NullEndLine_ReadsToEndOfContent() + { + // Act + List lines = FileEditor.SliceLines("one\ntwo\nthree", 2, endLine: null); + + // Assert + Assert.Equal(2, lines.Count); + Assert.Equal("two\nthree", string.Concat(lines)); + } + + [Fact] + public void SliceLines_EndLinePastLastLine_IsClamped() + { + // Act + List lines = FileEditor.SliceLines("one\ntwo\n", 1, 99); + + // Assert + Assert.Equal(2, lines.Count); + Assert.Equal("one\ntwo\n", string.Concat(lines)); + } + + [Theory] + [InlineData(0, null)] + [InlineData(-1, null)] + [InlineData(1, 0)] + [InlineData(3, 2)] + [InlineData(4, null)] + public void SliceLines_InvalidRange_Throws(int startLine, int? endLine) + { + // Act & Assert — "one\ntwo\nthree" has three lines. + Assert.Throws(() => FileEditor.SliceLines("one\ntwo\nthree", startLine, endLine)); + } + + [Fact] + public void SliceLines_EmptyContent_HasNoAddressableLines() + { + // Act & Assert — matches ApplyReplaceLines, which also rejects line 1 of an empty file. + Assert.Throws(() => FileEditor.SliceLines(string.Empty, 1, null)); + } + + #endregion } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/FileSystemAgentFileStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/FileSystemAgentFileStoreTests.cs index 20d1a8333d6..c8b9600028d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/FileSystemAgentFileStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/FileSystemAgentFileStoreTests.cs @@ -293,6 +293,112 @@ public async Task SearchFilesAsync_FindsMatchAsync() Assert.Contains("error", results[0].Snippet); } + // Both stores number through AgentFileStore.ScanContent, so the line, terminator and snippet-offset + // rules are pinned once in AgentFileStoreContractTests. These cover the same ground against real + // files, where content arrives through a decoded read rather than an in-memory string. + + [Fact] + public async Task SearchFilesAsync_ReportsLinesVerbatimAsync() + { + // Arrange + await this._store.WriteAsync("notes.md", "Line one\nLine two with match\nLine three\nLine four with match"); + + // Act + var results = await this._store.SearchAsync("", "match"); + + // Assert + Assert.Single(results); + Assert.Equal(2, results[0].MatchingLines.Count); + Assert.Equal(2, results[0].MatchingLines[0].LineNumber); + // Lines are reported verbatim, so an interior line keeps its terminator. + Assert.Equal("Line two with match\n", results[0].MatchingLines[0].Line); + Assert.Equal(4, results[0].MatchingLines[1].LineNumber); + // The last line has no terminator in the content, so none is reported. + Assert.Equal("Line four with match", results[0].MatchingLines[1].Line); + } + + [Fact] + public async Task SearchFilesAsync_ReportsCrlfLinesVerbatimAsync() + { + // Arrange + await this._store.WriteAsync("notes.md", "alpha\r\nbeta match\r\ngamma\r\n"); + + // Act + var results = await this._store.SearchAsync("", "match"); + + // Assert — the CRLF is preserved, so the line can be fed back to replace_lines unchanged. + Assert.Single(results); + Assert.Single(results[0].MatchingLines); + Assert.Equal(2, results[0].MatchingLines[0].LineNumber); + Assert.Equal("beta match\r\n", results[0].MatchingLines[0].Line); + } + + [Fact] + public async Task SearchFilesAsync_TrailingNewline_DoesNotReportAnExtraLineAsync() + { + // Arrange — a newline-terminated file has as many lines as the line editor sees, not one more. + await this._store.WriteAsync("notes.md", "a\nb\n"); + + // Act — a pattern that also matches an empty line. + var results = await this._store.SearchAsync("", "^.*$"); + + // Assert + Assert.Single(results); + Assert.Equal(2, results[0].MatchingLines.Count); + Assert.Equal("a\n", results[0].MatchingLines[0].Line); + Assert.Equal("b\n", results[0].MatchingLines[1].Line); + } + + [Fact] + public async Task SearchFilesAsync_LoneCarriageReturn_SplitsLikeTheLineEditorAsync() + { + // Arrange — a lone '\r' terminates a line for the line editor, so grep must agree. + await this._store.WriteAsync("notes.md", "alpha\rbeta match\rgamma"); + + // Act + var results = await this._store.SearchAsync("", "match"); + + // Assert + Assert.Single(results); + Assert.Single(results[0].MatchingLines); + Assert.Equal(2, results[0].MatchingLines[0].LineNumber); + Assert.Equal("beta match\r", results[0].MatchingLines[0].Line); + } + + [Theory] + [InlineData("alpha\r\nbeta match\r\ngamma\r\n")] + [InlineData("alpha\rbeta match\rgamma")] + [InlineData("alpha\nbeta match\ngamma\n")] + public async Task SearchFilesAsync_EndAnchoredPatternMatchesRegardlessOfTerminatorAsync(string content) + { + // Arrange — the pattern anchors to the end of the line's text, which is "beta match". + await this._store.WriteAsync("notes.md", content); + + // Act + var results = await this._store.SearchAsync("", "match$"); + + // Assert — the terminator is not part of the text the pattern is matched against. + Assert.Single(results); + Assert.Single(results[0].MatchingLines); + Assert.Equal(2, results[0].MatchingLines[0].LineNumber); + } + + [Fact] + public async Task SearchFilesAsync_SnippetIsAnchoredAtTheMatchAsync() + { + // Arrange — the leading line is long enough that the ±50 char snippet window is not clamped to + // the start of the file, so an off-by-one in the per-line offset would shift the snippet. + string padding = new('x', 60); + await this._store.WriteAsync("notes.md", $"{padding}\nneedle\n"); + + // Act + var results = await this._store.SearchAsync("", "needle"); + + // Assert — the match starts at index 61, so the snippet starts at index 11. + Assert.Single(results); + Assert.Equal($"{new string('x', 49)}\nneedle\n", results[0].Snippet); + } + [Fact] public async Task SearchFilesAsync_GlobFilter_ExcludesNonMatchingAsync() { diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/InMemoryAgentFileStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/InMemoryAgentFileStoreTests.cs index 722dc8f7356..7907007a039 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/InMemoryAgentFileStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Harness/FileStore/InMemoryAgentFileStoreTests.cs @@ -207,11 +207,106 @@ public async Task SearchFiles_ReturnsMatchingLineNumbersAsync() Assert.Single(results); Assert.Equal(2, results[0].MatchingLines.Count); Assert.Equal(2, results[0].MatchingLines[0].LineNumber); - Assert.Equal("Line two with match", results[0].MatchingLines[0].Line); + // Lines are reported verbatim, so an interior line keeps its terminator. + Assert.Equal("Line two with match\n", results[0].MatchingLines[0].Line); Assert.Equal(4, results[0].MatchingLines[1].LineNumber); + // The last line has no terminator in the content, so none is reported. Assert.Equal("Line four with match", results[0].MatchingLines[1].Line); } + [Fact] + public async Task SearchFiles_ReportsCrlfLinesVerbatimAsync() + { + // Arrange + var store = new InMemoryAgentFileStore(); + await store.WriteAsync("folder/notes.md", "alpha\r\nbeta match\r\ngamma\r\n"); + + // Act + var results = await store.SearchAsync("folder", "match"); + + // Assert — the CRLF is preserved, so the line can be fed back to replace_lines unchanged. + Assert.Single(results); + Assert.Single(results[0].MatchingLines); + Assert.Equal(2, results[0].MatchingLines[0].LineNumber); + Assert.Equal("beta match\r\n", results[0].MatchingLines[0].Line); + } + + [Fact] + public async Task SearchFiles_TrailingNewline_DoesNotReportAnExtraLineAsync() + { + // Arrange — a newline-terminated file has as many lines as the line editor sees, not one more. + var store = new InMemoryAgentFileStore(); + await store.WriteAsync("folder/notes.md", "a\nb\n"); + + // Act — a pattern that also matches an empty line. + var results = await store.SearchAsync("folder", "^.*$"); + + // Assert + Assert.Single(results); + Assert.Equal(2, results[0].MatchingLines.Count); + Assert.Equal("a\n", results[0].MatchingLines[0].Line); + Assert.Equal("b\n", results[0].MatchingLines[1].Line); + } + + [Fact] + public async Task SearchFiles_LoneCarriageReturn_SplitsLikeTheLineEditorAsync() + { + // Arrange — a lone '\r' terminates a line for the line editor, so grep must agree. + var store = new InMemoryAgentFileStore(); + await store.WriteAsync("folder/notes.md", "alpha\rbeta match\rgamma"); + + // Act + var results = await store.SearchAsync("folder", "match"); + + // Assert + Assert.Single(results); + Assert.Single(results[0].MatchingLines); + Assert.Equal(2, results[0].MatchingLines[0].LineNumber); + Assert.Equal("beta match\r", results[0].MatchingLines[0].Line); + } + + [Theory] + [InlineData("alpha\r\nbeta match\r\ngamma\r\n")] + [InlineData("alpha\rbeta match\rgamma")] + [InlineData("alpha\nbeta match\ngamma\n")] + public async Task SearchFiles_EndAnchoredPatternMatchesRegardlessOfTerminatorAsync(string content) + { + // Arrange — the pattern anchors to the end of the line's text, which is "beta match". + var store = new InMemoryAgentFileStore(); + await store.WriteAsync("folder/notes.md", content); + + // Act + var results = await store.SearchAsync("folder", "match$"); + + // Assert — the terminator is not part of the text the pattern is matched against. + Assert.Single(results); + Assert.Single(results[0].MatchingLines); + Assert.Equal(2, results[0].MatchingLines[0].LineNumber); + } + + [Theory] + [InlineData("\n")] + [InlineData("\r")] + [InlineData("\r\n")] + public async Task SearchFiles_SnippetIsAnchoredAtTheMatchAsync(string terminator) + { + // Arrange — the leading line is long enough that the ±50 char snippet window is not clamped to + // the start of the file, so an off-by-one in the per-line offset would shift the snippet. Every + // terminator length is covered: advancing by content length plus one would pass LF and CR but + // fall a character short on CRLF. + var store = new InMemoryAgentFileStore(); + string padding = new('x', 60); + await store.WriteAsync("folder/notes.md", $"{padding}{terminator}needle{terminator}"); + + // Act + var results = await store.SearchAsync("folder", "needle"); + + // Assert — the snippet starts 50 characters before the match, which lands that many characters + // into the padding minus the terminator the match sits behind. + Assert.Single(results); + Assert.Equal($"{new string('x', 50 - terminator.Length)}{terminator}needle{terminator}", results[0].Snippet); + } + [Fact] public async Task SearchFiles_CaseInsensitiveAsync() { From ea9aad81249085a14a65836981edab5f3481495d Mon Sep 17 00:00:00 2001 From: wang ke Date: Fri, 4 Sep 2026 12:10:52 +0000 Subject: [PATCH 23/85] Python: preserve recursive serialization compatibility (#7790) --- .../core/agent_framework/_serialization.py | 148 +++++++++++------- .../tests/core/test_serializable_mixin.py | 85 ++++++++-- 2 files changed, 165 insertions(+), 68 deletions(-) diff --git a/python/packages/core/agent_framework/_serialization.py b/python/packages/core/agent_framework/_serialization.py index fc383ded5f1..3751688f91c 100644 --- a/python/packages/core/agent_framework/_serialization.py +++ b/python/packages/core/agent_framework/_serialization.py @@ -9,9 +9,11 @@ import re from collections.abc import Mapping, MutableMapping from dataclasses import asdict, is_dataclass -from datetime import date, datetime +from datetime import date, datetime, time from functools import lru_cache -from typing import Any, ClassVar, Protocol, TypeGuard, TypeVar, cast, runtime_checkable +from typing import Any, ClassVar, Final, Protocol, TypeGuard, TypeVar, cast, runtime_checkable + +from typing_extensions import Sentinel logger = logging.getLogger("agent_framework") @@ -19,6 +21,7 @@ ProtocolT = TypeVar("ProtocolT", bound="SerializationProtocol") _JSON_SCALAR_TYPES = (str, int, float, bool, type(None)) _DIRECT_JSON_TYPES = (*_JSON_SCALAR_TYPES, list, dict) +_SKIP_SERIALIZATION: Final = Sentinel("SKIP_SERIALIZATION") # Regex pattern for converting CamelCase to snake_case _CAMEL_TO_SNAKE_PATTERN = re.compile(r"(? TypeGuard[SerializationProtocol]: return callable(getattr(value, "to_dict", None)) and callable(getattr(value, "from_dict", None)) +def _serialize_value( + value: Any, + *, + exclude: set[str] | None, + exclude_none: bool, + attribute_name: str, + active_container_ids: set[int] | None = None, + stringify_dict_keys: bool = False, +) -> Any: + """Recursively serialize a value while preserving skip semantics.""" + if active_container_ids is None: + active_container_ids = set() + if type(value) in _JSON_SCALAR_TYPES: + return value + if _is_serialization_protocol(value): + serialized = value.to_dict(exclude=exclude, exclude_none=exclude_none) + return _serialize_value( + serialized, + exclude=exclude, + exclude_none=exclude_none, + attribute_name=attribute_name, + active_container_ids=active_container_ids, + ) + if isinstance(value, list): + value_as_list = cast(list[Any], value) + container_id = id(value_as_list) + if container_id in active_container_ids: + raise ValueError("Circular reference detected") + active_container_ids.add(container_id) + try: + serialized_list: list[Any] = [] + for item in value_as_list: + serialized = _serialize_value( + item, + exclude=exclude, + exclude_none=exclude_none, + attribute_name=attribute_name, + active_container_ids=active_container_ids, + ) + if serialized is not _SKIP_SERIALIZATION: + serialized_list.append(serialized) + return serialized_list + finally: + active_container_ids.remove(container_id) + if isinstance(value, dict): + value_as_dict = cast(dict[Any, Any], value) + container_id = id(value_as_dict) + if container_id in active_container_ids: + raise ValueError("Circular reference detected") + active_container_ids.add(container_id) + try: + serialized_dict: dict[Any, Any] = {} + for raw_key, item in value_as_dict.items(): + dict_key = str(raw_key) if stringify_dict_keys else raw_key + if isinstance(item, (datetime, date, time)): + serialized_dict[dict_key] = str(item) + continue + serialized = _serialize_value( + item, + exclude=exclude, + exclude_none=exclude_none, + attribute_name=attribute_name, + active_container_ids=active_container_ids, + ) + if serialized is not _SKIP_SERIALIZATION: + serialized_dict[dict_key] = serialized + return serialized_dict + finally: + active_container_ids.remove(container_id) + if is_serializable(value): + return value + logger.debug(f"Skipping non-serializable value in attribute '{attribute_name}' of type {type(value).__name__}") + return _SKIP_SERIALIZATION + + def _iter_instance_fields(instance: Any) -> dict[str, Any]: """Return attributes stored in ``__dict__`` or slots.""" fields = dict(getattr(instance, "__dict__", {})) @@ -389,63 +467,15 @@ def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) if key not in combined_exclude and not key.startswith("_"): if exclude_none and value is None: continue - if type(value) in _JSON_SCALAR_TYPES: - result[key] = value - continue - # Recursively serialize SerializationProtocol objects - if _is_serialization_protocol(value): - result[key] = value.to_dict(exclude=exclude, exclude_none=exclude_none) - continue - # Handle lists containing SerializationProtocol objects - if isinstance(value, list): - value_as_list: list[Any] = [] - for item in cast(list[Any], value): - if type(item) in _JSON_SCALAR_TYPES: - value_as_list.append(item) - continue - if _is_serialization_protocol(item): - value_as_list.append(item.to_dict(exclude=exclude, exclude_none=exclude_none)) - continue - if is_serializable(item): - value_as_list.append(item) - continue - logger.debug( - f"Skipping non-serializable item in list attribute '{key}' of type {type(item).__name__}" - ) - result[key] = value_as_list - continue - # Handle dicts containing SerializationProtocol values - if isinstance(value, dict): - from datetime import date, datetime, time - - serialized_dict: dict[str, Any] = {} - for raw_key, v in cast(dict[Any, Any], value).items(): - dict_key = str(raw_key) - # Convert datetime objects to strings - if isinstance(v, (datetime, date, time)): - serialized_dict[dict_key] = str(v) - continue - if type(v) in _JSON_SCALAR_TYPES: - serialized_dict[dict_key] = v - continue - if _is_serialization_protocol(v): - serialized_dict[dict_key] = v.to_dict(exclude=exclude, exclude_none=exclude_none) - continue - # Check if the value is JSON serializable - if is_serializable(v): - serialized_dict[dict_key] = v - continue - logger.debug( - f"Skipping non-serializable value for key '{dict_key}' in dict attribute '{key}' " - f"of type {type(v).__name__}" - ) - result[key] = serialized_dict - continue - # Directly include JSON serializable values - if is_serializable(value): - result[key] = value - continue - logger.debug(f"Skipping non-serializable attribute '{key}' of type {type(value).__name__}") + serialized = _serialize_value( + value, + exclude=exclude, + exclude_none=exclude_none, + attribute_name=key, + stringify_dict_keys=isinstance(value, dict), + ) + if serialized is not _SKIP_SERIALIZATION: + result[key] = serialized return result diff --git a/python/packages/core/tests/core/test_serializable_mixin.py b/python/packages/core/tests/core/test_serializable_mixin.py index d5b14cb0015..d31685c8d68 100644 --- a/python/packages/core/tests/core/test_serializable_mixin.py +++ b/python/packages/core/tests/core/test_serializable_mixin.py @@ -3,7 +3,9 @@ """Tests for SerializationMixin functionality.""" import copy +import json import logging +from datetime import date, datetime, time from typing import Any import pytest @@ -304,19 +306,82 @@ def __init__(self, items_dict: dict): assert data["items_dict"]["a"]["name"] == "item1" assert data["items_dict"]["b"]["name"] == "item2" - def test_to_dict_with_datetime_in_dict(self): - """Test to_dict converts datetime objects in dicts to strings.""" - from datetime import datetime + def test_to_dict_recursively_serializes_nested_containers(self): + """Test to_dict serializes protocol objects nested in containers.""" + + class ItemClass(SerializationMixin): + def __init__(self, name: str): + self.name = name + + class ContainerClass(SerializationMixin): + def __init__(self, payload: dict): + self.payload = payload + + container = ContainerClass(payload={"groups": [{"items": [ItemClass(name="item1")]}]}) + + data = container.to_dict() + + assert data["payload"]["groups"][0]["items"][0]["name"] == "item1" + assert json.loads(container.to_json()) == data + + def test_to_dict_preserves_nested_non_string_dict_keys(self): + """Test recursive serialization preserves non-string keys below the attribute dictionary.""" + + class ContainerClass(SerializationMixin): + def __init__(self, payload: dict): + self.payload = payload + + container = ContainerClass(payload={7: "direct", "nested": {True: "enabled", None: "missing"}}) + + data = container.to_dict() + + assert data["payload"]["7"] == "direct" + assert data["payload"]["nested"] == {True: "enabled", None: "missing"} + json_payload = json.loads(container.to_json())["payload"] + assert json_payload["7"] == "direct" + assert json_payload["nested"] == {"true": "enabled", "null": "missing"} + + def test_to_dict_rejects_circular_list(self): + """Test recursive serialization reports a controlled error for a circular list.""" + + class ContainerClass(SerializationMixin): + def __init__(self, payload: list[Any]): + self.payload = payload + + payload: list[Any] = [] + payload.append(payload) + + with pytest.raises(ValueError, match="Circular reference detected"): + ContainerClass(payload).to_dict() + + def test_to_dict_rejects_circular_dict(self): + """Test recursive serialization reports a controlled error for a circular dictionary.""" + + class ContainerClass(SerializationMixin): + def __init__(self, payload: dict[str, Any]): + self.payload = payload + + payload: dict[str, Any] = {} + payload["self"] = payload + + with pytest.raises(ValueError, match="Circular reference detected"): + ContainerClass(payload).to_dict() + + @pytest.mark.parametrize("value", [datetime(2025, 1, 27, 12), date(2025, 1, 27), time(12)]) + def test_to_dict_only_converts_date_time_in_dict_values(self, value): + """Test to_dict preserves the existing date/time conversion contexts.""" class TestClass(SerializationMixin): - def __init__(self, metadata: dict): - self.metadata = metadata + def __init__(self): + self.top_level = value + self.items = [value] + self.metadata = {"created_at": value} - now = datetime(2025, 1, 27, 12, 0, 0) - obj = TestClass(metadata={"created_at": now}) - data = obj.to_dict() + data = TestClass().to_dict() - assert isinstance(data["metadata"]["created_at"], str) + assert "top_level" not in data + assert data["items"] == [] + assert data["metadata"]["created_at"] == str(value) def test_to_dict_skips_non_serializable_in_dict(self, caplog): """Test to_dict skips non-serializable values in dicts with debug logging.""" @@ -586,6 +651,7 @@ def __init__(self, raw_representation: Any): def test_pickle_restores_slot_fields(self): """Pickle state should include fields declared in slots.""" + class TestClass(SerializationMixin): __slots__ = ("value",) @@ -614,6 +680,7 @@ def __init__(self): def test_pickle_omission_is_separate_from_shallow_copy_policy(self): """Fields shallow-copied by default remain persistent unless explicitly omitted.""" + class TestClass(SerializationMixin): _PICKLE_OMIT_FIELDS = set() From 9e59a18eb959b170e526853ca75cc33c1218da10 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:33:17 +0000 Subject: [PATCH 24/85] Bump Anthropic from 12.42.0 to 12.43.0 (#8028) * Bump Anthropic from 12.42.0 to 12.43.0 --- updated-dependencies: - dependency-name: Anthropic dependency-version: 12.43.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * Implement Anthropic beta organization interface Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> --- dotnet/Directory.Packages.props | 2 +- .../Extensions/AnthropicBetaServiceExtensionsTests.cs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 47b234fe8a6..c9346739261 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -11,7 +11,7 @@ - + diff --git a/dotnet/tests/Microsoft.Agents.AI.Anthropic.UnitTests/Extensions/AnthropicBetaServiceExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Anthropic.UnitTests/Extensions/AnthropicBetaServiceExtensionsTests.cs index 6ff7b3cd8d5..45d027fc261 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Anthropic.UnitTests/Extensions/AnthropicBetaServiceExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Anthropic.UnitTests/Extensions/AnthropicBetaServiceExtensionsTests.cs @@ -511,6 +511,8 @@ public TestBetaService(IAnthropicClient client) public global::Anthropic.Services.Beta.ITunnelService Tunnels => throw new NotImplementedException(); + public global::Anthropic.Services.Beta.IOrganizationService Organization => throw new NotImplementedException(); + public IBetaService WithOptions(Func modifier) { throw new NotImplementedException(); From 0ebaaabb15f59d7344c0651f37a1a72e80696f52 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:49:43 +0000 Subject: [PATCH 25/85] .NET: Bump AgentMemory.AgentFramework from 1.4.1 to 1.5.0 (#8027) * Bump AgentMemory.AgentFramework from 1.4.1 to 1.5.0 --- updated-dependencies: - dependency-name: AgentMemory.AgentFramework dependency-version: 1.5.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * Bump AgentMemory to 1.5.0 to match AgentMemory.AgentFramework Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> * Add dependabot group for AgentMemory packages Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- .github/dependabot.yml | 5 +++++ .../AgentWithMemory_Step06_MemoryUsingAgentMemory.csproj | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a2e846a207f..d3f94de97ad 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,6 +11,11 @@ updates: schedule: interval: "cron" cronjob: "0 8 * * 4,0" # Every Thursday(4) and Sunday(0) at 8:00 UTC + groups: + agent-memory: + patterns: + - "AgentMemory" + - "AgentMemory.AgentFramework" ignore: # For all System.* and Microsoft.Extensions/Bcl.* packages, ignore all major version updates - dependency-name: "System.*" diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory.csproj b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory.csproj index 4224265ea15..f9521492ef3 100644 --- a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory.csproj +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory.csproj @@ -38,8 +38,8 @@ - - + + From 957cb61c9e30f82a64eb755238665f83059b2d49 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:09:15 +0000 Subject: [PATCH 26/85] Chore(deps): Bump actions/labeler from 6.1.0 to 7.0.0 (#8060) Bumps [actions/labeler](https://github.com/actions/labeler) from 6.1.0 to 7.0.0. - [Release notes](https://github.com/actions/labeler/releases) - [Commits](https://github.com/actions/labeler/compare/f27b608878404679385c85cfa523b85ccb86e213...bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13) --- updated-dependencies: - dependency-name: actions/labeler dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/label-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/label-pr.yml b/.github/workflows/label-pr.yml index 4771bc538af..8952194b36c 100644 --- a/.github/workflows/label-pr.yml +++ b/.github/workflows/label-pr.yml @@ -46,7 +46,7 @@ jobs: repository: ${{ github.repository }} fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }} - - uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0 + - uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0 with: repo-token: ${{ steps.github-auth.outputs.token }} From 243c0fc592c8fcd3211cfc5430dd5867f081c6fc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:14:54 +0000 Subject: [PATCH 27/85] Chore(deps): Bump github/codeql-action/analyze from 4.37.3 to 4.37.9 (#8067) Bumps [github/codeql-action/analyze](https://github.com/github/codeql-action) from 4.37.3 to 4.37.9. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81...cdf488f595d80d6e07e03d4674febd5ab45fa938) --- updated-dependencies: - dependency-name: github/codeql-action/analyze dependency-version: 4.37.9 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 40a2e12df20..4ce54580f94 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -64,6 +64,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: category: "/language:${{matrix.language}}" From 1dd7944da576e526a8aaa9c519c181e0472f3cdd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:15:14 +0000 Subject: [PATCH 28/85] Chore(deps): Bump github/codeql-action/init from 4.37.3 to 4.37.9 (#8066) Bumps [github/codeql-action/init](https://github.com/github/codeql-action) from 4.37.3 to 4.37.9. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81...cdf488f595d80d6e07e03d4674febd5ab45fa938) --- updated-dependencies: - dependency-name: github/codeql-action/init dependency-version: 4.37.9 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 4ce54580f94..418b6fca3dd 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -38,7 +38,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. From 725b94b28ca79c8c273bcef0f3fa559d0d4d92d1 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:43:52 +0000 Subject: [PATCH 29/85] .NET: [BREAKING] Clarify A2A agent run modes (#8032) * backup * .NET: Always create A2A agent run options Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1a876988-01c0-4110-9517-77aba4bf41a8 * .NET: Decouple A2A run mode from agent options Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1a876988-01c0-4110-9517-77aba4bf41a8 * .NET: Clarify dynamic A2A run mode scope Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1a876988-01c0-4110-9517-77aba4bf41a8 * .NET: Remove redundant run mode assertion Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1a876988-01c0-4110-9517-77aba4bf41a8 --------- Co-authored-by: SergeyMenshykh Copilot-Session: 1a876988-01c0-4110-9517-77aba4bf41a8 --- .../A2AAgentHandler.cs | 68 +++++------ .../A2AServerRegistrationOptions.cs | 2 +- .../A2AServerServiceCollectionExtensions.cs | 2 +- .../AgentRunMode.cs | 53 ++++----- .../A2AAgentHandlerTests.cs | 111 +++++++++--------- .../A2AEndpointRouteBuilderExtensionsTests.cs | 2 +- ...AServerServiceCollectionExtensionsTests.cs | 4 +- .../AgentRunModeTests.cs | 63 +++++----- 8 files changed, 150 insertions(+), 155 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs index 72cbde5a6bd..8d3de412b6b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs @@ -33,7 +33,7 @@ internal sealed class A2AAgentHandler : IAgentHandler /// Initializes a new instance of the class. /// /// The hosted agent that provides the execution logic. - /// Controls whether the agent runs in background mode. + /// Controls which A2A artifact the agent response is returned as. public A2AAgentHandler( AIHostAgent hostAgent, AgentRunMode runMode) @@ -80,20 +80,20 @@ public async Task CancelAsync(RequestContext context, AgentEventQueue eventQueue /// The queue the response events are written to. /// /// to run the agent to completion before emitting a single completed task; - /// to emit task updates as they are produced. Ignored when the server disallows - /// background responses, because a message response is always aggregated. + /// to emit task updates as they are produced. Ignored when the server is configured + /// through to return a message, because a message response is always aggregated. /// /// A to cancel the operation. /// /// The response shape is decided by two independent inputs: /// /// - /// Whether the server allows background responses. This is configured per agent registration, for example: + /// Which A2A artifact the server returns. This is configured per agent registration, for example: /// /// builder.AddA2AServer(agent, (A2AServerRegistrationOptions options) => - /// options.AgentRunMode = AgentRunMode.AllowBackgroundIfSupported); + /// options.AgentRunMode = AgentRunMode.ReturnTask); /// - /// Use AgentRunMode.DisallowBackground to always respond with a message instead of a task. + /// Use AgentRunMode.ReturnMessage to always respond with a message instead of a task. /// /// /// Whether the client asked for an immediate response. In the A2A protocol this is the @@ -104,17 +104,20 @@ public async Task CancelAsync(RequestContext context, AgentEventQueue eventQueue /// The resulting combinations are: /// /// - /// Server allows background responses and ReturnImmediately = true: returns the initial task, then the - /// rest of the updates piece by piece. + /// Server is configured through to return a task and ReturnImmediately = true: + /// returns the initial task, then the rest of the updates piece by piece. /// /// - /// Server allows background responses and ReturnImmediately = false: returns a single completed task. + /// Server is configured through to return a task and ReturnImmediately = false: + /// returns a single completed task. /// /// - /// Server disallows background responses and ReturnImmediately = true: returns a message. + /// Server is configured through to return a message and ReturnImmediately = true: + /// returns a message. /// /// - /// Server disallows background responses and ReturnImmediately = false: returns a message. + /// Server is configured through to return a message and ReturnImmediately = false: + /// returns a message. /// /// /// @@ -133,11 +136,11 @@ private async Task HandleNewMessageAsync(RequestContext context, AgentEventQueue List chatMessages = context.Message is not null ? [context.Message.ToChatMessage()] : []; - var options = CreateRunOptions(context); - - // Decide whether to run in background based on user preferences and agent capabilities + // Decide which A2A artifact to return based on the configured run mode. var decisionContext = new A2ARunDecisionContext(context); - var returnTask = await this._runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false); + var returnTask = await this._runMode.ShouldReturnTaskAsync(decisionContext, cancellationToken).ConfigureAwait(false); + + var options = CreateRunOptions(context); var updates = this._hostAgent.RunStreamingAsync(chatMessages, session, options, cancellationToken); @@ -148,20 +151,20 @@ private async Task HandleNewMessageAsync(RequestContext context, AgentEventQueue var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId); if (aggregateTaskUpdates) { - // The server allows background responses, but the non-streaming client request has + // The server is configured through AgentRunMode to return a task, but the non-streaming client request has // ReturnImmediately disabled, so collect all updates and return a completed task. await AggregateTaskUpdatesAsync(updates, taskUpdater, eventQueue, cancellationToken).ConfigureAwait(false); } else { - // The server allows background responses and this is either a streaming request or a + // The server is configured through AgentRunMode to return a task and this is either a streaming request or a // non-streaming request with ReturnImmediately enabled, so emit task updates as they arrive. await StreamTaskUpdatesAsync(updates, taskUpdater, cancellationToken).ConfigureAwait(false); } } else { - // The server disallows background responses, so return one aggregated message regardless + // The server is configured through AgentRunMode to return a message, so return one aggregated message regardless // of the client request's ReturnImmediately value. await StreamMessageUpdatesAsync(contextId, updates, eventQueue, cancellationToken).ConfigureAwait(false); } @@ -179,10 +182,7 @@ private async Task HandleTaskUpdateAsync(RequestContext context, AgentEventQueue List chatMessages = ExtractChatMessagesFromTaskHistory(context.Task); - var decisionContext = new A2ARunDecisionContext(context); - var allowBackgroundResponses = await this._runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false); - - var options = CreateRunOptions(context, allowBackgroundResponses); + var options = CreateRunOptions(context); AgentResponse response; try @@ -233,13 +233,8 @@ private async Task HandleTaskUpdateAsync(RequestContext context, AgentEventQueue /// MessageSendParams.metadata and MessageSendParams.configuration to the hosted agent. /// /// The A2A request context of the incoming request. - /// - /// The value to assign to . Defaults to , which leaves it unset. - /// - /// - /// The run options to invoke the agent with, or when there is nothing to forward. - /// - private static AgentRunOptions? CreateRunOptions(RequestContext context, bool? allowBackgroundResponses = null) + /// The run options to invoke the agent with. + private static AgentRunOptions CreateRunOptions(RequestContext context) { AdditionalPropertiesDictionary? additionalProperties = context.Metadata is { Count: > 0 } ? context.Metadata.ToAdditionalProperties() @@ -252,14 +247,8 @@ private async Task HandleTaskUpdateAsync(RequestContext context, AgentEventQueue (additionalProperties ??= [])[ConfigurationPropertyKey] = configuration; } - if (allowBackgroundResponses is null && additionalProperties is null) - { - return null; - } - return new AgentRunOptions { - AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = additionalProperties }; } @@ -294,7 +283,8 @@ private static List ExtractChatMessagesFromTaskHistory(AgentTask? a /// Emits a task and streams the agent updates into it as artifacts as they are produced. /// /// - /// Handles the case where the server allows background responses and the response is delivered incrementally: + /// Handles the case where the server is configured through to return a task and the + /// response is delivered incrementally: /// either a streaming (message/stream) request, or a non-streaming request with /// ReturnImmediately = true. In the latter case the caller receives the initial task immediately and /// obtains the remaining updates by polling the task. @@ -342,7 +332,8 @@ private static async Task StreamTaskUpdatesAsync(IAsyncEnumerable /// - /// Handles the case where the server allows background responses and a non-streaming client sent + /// Handles the case where the server is configured through to return a task and a + /// non-streaming client sent /// ReturnImmediately = false, meaning it wants the final result in the response rather than a task /// it has to poll. No task event is emitted until the agent stream finishes, because the server returns on the /// first task event; emitting early would hand the caller an in-progress task instead of a completed one. @@ -384,7 +375,8 @@ await eventQueue.AddArtifactAsync( /// Consumes the agent updates and emits the aggregated result as a single message. /// /// - /// Handles the case where the server disallows background responses, which applies regardless of the client's + /// Handles the case where the server is configured through to return a message, which + /// applies regardless of the client's /// ReturnImmediately value: a message is not a long-running entity, so there is nothing to return early /// or poll for and the full agent run is always aggregated into one message. An empty message is emitted when /// the agent produces no messages. diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerRegistrationOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerRegistrationOptions.cs index 7bd30f9a7cd..2ef3f6be779 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerRegistrationOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerRegistrationOptions.cs @@ -16,7 +16,7 @@ public sealed class A2AServerRegistrationOptions /// Gets or sets the agent run mode that controls how the agent responds to A2A requests. /// /// - /// When , defaults to . + /// When , defaults to . /// public AgentRunMode? AgentRunMode { get; set; } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs index 556a0d931a3..8e8240493c2 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs @@ -186,7 +186,7 @@ private static A2AServer CreateA2AServer(IServiceProvider serviceProvider, AIAge if (agentHandler is null) { var agentSessionStore = serviceProvider.GetKeyedService(agent.Name); - var runMode = options?.AgentRunMode ?? AgentRunMode.DisallowBackground; + var runMode = options?.AgentRunMode ?? AgentRunMode.ReturnMessage; // Ensure that we have an IsolationKeyScopedAgentSessionStore registered. if (agentSessionStore?.GetService() is null) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs index 3abb90afb6d..95519b3255d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs @@ -10,7 +10,8 @@ namespace Microsoft.Agents.AI.Hosting.A2A; /// -/// Specifies how the A2A hosting layer determines whether to run in background or not. +/// Specifies which A2A protocol artifact the hosting layer returns for a run of an : +/// an AgentMessage or an AgentTask. /// [Experimental(DiagnosticIds.Experiments.AIResponseContinuations)] public sealed class AgentRunMode : IEquatable @@ -20,48 +21,46 @@ public sealed class AgentRunMode : IEquatable private const string DynamicValue = "dynamic"; private readonly string _value; - private readonly Func>? _runInBackground; + private readonly Func>? _returnTask; - private AgentRunMode(string value, Func>? runInBackground = null) + private AgentRunMode(string value, Func>? returnTask = null) { this._value = value; - this._runInBackground = runInBackground; + this._returnTask = returnTask; } /// - /// Disallows the background responses from the agent. Is equivalent to configuring as false. - /// In the A2A protocol terminology will make responses be returned as AgentMessage. + /// Returns the agent response as an AgentMessage. The updates produced by the agent are aggregated + /// into a single message. /// - public static AgentRunMode DisallowBackground => new(MessageValue); + public static AgentRunMode ReturnMessage => new(MessageValue); /// - /// Allows the background responses from the agent. Is equivalent to configuring as true. - /// In the A2A protocol terminology will make responses be returned as AgentTask if the agent supports background responses, and as AgentMessage otherwise. + /// Returns the agent response as an AgentTask, allowing the caller to track its lifecycle and to + /// receive the result incrementally. /// - public static AgentRunMode AllowBackgroundIfSupported => new(TaskValue); + public static AgentRunMode ReturnTask => new(TaskValue); /// - /// The agent run mode is decided by the supplied delegate. - /// The delegate receives an with the incoming - /// message and returns a boolean specifying whether to run the agent in background mode. - /// indicates that the agent should run in background mode and return an - /// AgentTask if the agent supports background mode; otherwise, it returns an AgentMessage - /// if the mode is not supported. indicates that the agent should run in - /// non-background mode and return an AgentMessage. + /// Defers the choice between an AgentMessage and an AgentTask to the supplied + /// delegate, which is invoked for each new-message request. The delegate receives + /// an describing the incoming request and returns to + /// return an AgentTask, or to return an AgentMessage. Continuations of an + /// existing task remain task responses and do not invoke the delegate. /// - /// - /// An async delegate that decides whether the response should be wrapped in an AgentTask. + /// + /// An async delegate that decides whether a new-message response is returned as an AgentTask. /// - public static AgentRunMode AllowBackgroundWhen(Func> runInBackground) + public static AgentRunMode ReturnTaskWhen(Func> returnTask) { - ArgumentNullException.ThrowIfNull(runInBackground); - return new(DynamicValue, runInBackground); + ArgumentNullException.ThrowIfNull(returnTask); + return new(DynamicValue, returnTask); } /// /// Determines whether the agent response should be returned as an AgentTask. /// - internal ValueTask ShouldRunInBackgroundAsync(A2ARunDecisionContext context, CancellationToken cancellationToken) + internal ValueTask ShouldReturnTaskAsync(A2ARunDecisionContext context, CancellationToken cancellationToken) { if (string.Equals(this._value, MessageValue, StringComparison.OrdinalIgnoreCase)) { @@ -74,9 +73,9 @@ internal ValueTask ShouldRunInBackgroundAsync(A2ARunDecisionContext contex } // Dynamic: delegate to custom callback. - if (this._runInBackground is not null) + if (this._returnTask is not null) { - return this._runInBackground(context, cancellationToken); + return this._returnTask(context, cancellationToken); } // No delegate provided — fall back to "message" behavior. @@ -87,7 +86,7 @@ internal ValueTask ShouldRunInBackgroundAsync(A2ARunDecisionContext contex public bool Equals(AgentRunMode? other) => other is not null && string.Equals(this._value, other._value, StringComparison.OrdinalIgnoreCase) - && ReferenceEquals(this._runInBackground, other._runInBackground); + && ReferenceEquals(this._returnTask, other._returnTask); /// public override bool Equals(object? obj) => this.Equals(obj as AgentRunMode); @@ -95,7 +94,7 @@ other is not null /// public override int GetHashCode() => HashCode.Combine( StringComparer.OrdinalIgnoreCase.GetHashCode(this._value), - RuntimeHelpers.GetHashCode(this._runInBackground)); + RuntimeHelpers.GetHashCode(this._returnTask)); /// public override string ToString() => this._value; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs index 8ab2c239a31..48999255b2e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs @@ -24,10 +24,10 @@ public sealed class A2AAgentHandlerTests private const string ConfigurationPropertyKey = "a2a.configuration"; /// - /// Verifies that when there is no request data to forward, null options are passed to RunStreamingAsync. + /// Verifies that when there is no request data to forward, empty options are passed to RunStreamingAsync. /// [Fact] - public async Task ExecuteAsync_WhenMetadataIsNull_PassesNullOptionsToRunStreamingAsync() + public async Task ExecuteAsync_WhenMetadataIsNull_PassesEmptyOptionsToRunStreamingAsync() { // Arrange AgentRunOptions? capturedOptions = null; @@ -40,7 +40,9 @@ public async Task ExecuteAsync_WhenMetadataIsNull_PassesNullOptionsToRunStreamin }); // Assert - Assert.Null(capturedOptions); + Assert.NotNull(capturedOptions); + Assert.Null(capturedOptions.AllowBackgroundResponses); + Assert.Null(capturedOptions.AdditionalProperties); } /// @@ -146,7 +148,7 @@ public async Task ExecuteAsync_WhenConfigurationRequestsImmediateReturn_DoesNotS AgentRunOptions? capturedOptions = null; A2AAgentHandler handler = CreateHandler( CreateAgentMock(options => capturedOptions = options), - runMode: AgentRunMode.DisallowBackground); + runMode: AgentRunMode.ReturnMessage); // Act await InvokeExecuteAsync(handler, new RequestContext @@ -250,7 +252,7 @@ public async Task ExecuteAsync_DynamicMode_WithFalseCallback_ReturnsMessageAsync // Arrange A2AAgentHandler handler = CreateHandler( CreateAgentMock(_ => { }), - runMode: AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(false))); + runMode: AgentRunMode.ReturnTaskWhen((_, _) => ValueTask.FromResult(false))); // Act var events = await CollectEventsAsync(handler, new RequestContext @@ -272,7 +274,7 @@ public async Task ExecuteAsync_DynamicMode_WithTrueCallback_ReturnsTaskAsync() // Arrange A2AAgentHandler handler = CreateHandler( CreateAgentMock(_ => { }), - runMode: AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(true))); + runMode: AgentRunMode.ReturnTaskWhen((_, _) => ValueTask.FromResult(true))); // Act var events = await CollectEventsAsync(handler, new RequestContext @@ -288,10 +290,10 @@ public async Task ExecuteAsync_DynamicMode_WithTrueCallback_ReturnsTaskAsync() #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. /// - /// Verifies that an immediate request emits the initial task and streams subsequent updates when background responses are allowed. + /// Verifies that an immediate request emits the initial task and streams subsequent updates in ReturnTask mode. /// [Fact] - public async Task ExecuteAsync_WhenBackgroundResponsesAllowedAndReturnImmediatelyTrue_StreamsTaskUpdatesAsync() + public async Task ExecuteAsync_WhenReturnTaskModeAndReturnImmediatelyTrue_StreamsTaskUpdatesAsync() { // Arrange AgentResponseUpdate[] updates = @@ -306,7 +308,7 @@ public async Task ExecuteAsync_WhenBackgroundResponsesAllowedAndReturnImmediatel ]; A2AAgentHandler handler = CreateHandler( CreateStreamingAgentMock(updates), - runMode: AgentRunMode.AllowBackgroundIfSupported); + runMode: AgentRunMode.ReturnTask); // Act var events = await CollectEventsAsync(handler, new RequestContext @@ -592,7 +594,7 @@ public async Task ExecuteAsync_WhenMessageIsNull_SucceedsWithEmptyMessagesAsync( } /// - /// Verifies that the dynamic AllowBackgroundWhen delegate receives the correct RequestContext. + /// Verifies that the dynamic ReturnTaskWhen delegate receives the correct RequestContext. /// [Fact] public async Task ExecuteAsync_DynamicMode_DelegateReceivesRequestContextAsync() @@ -601,7 +603,7 @@ public async Task ExecuteAsync_DynamicMode_DelegateReceivesRequestContextAsync() A2ARunDecisionContext? capturedContext = null; A2AAgentHandler handler = CreateHandler( CreateAgentMock(_ => { }), - runMode: AgentRunMode.AllowBackgroundWhen((ctx, _) => + runMode: AgentRunMode.ReturnTaskWhen((ctx, _) => { capturedContext = ctx; return ValueTask.FromResult(false); @@ -698,10 +700,10 @@ public async Task ExecuteAsync_Streaming_EnqueuesSingleAggregatedMessageAsync() } /// - /// Verifies that allowing background responses emits a task lifecycle in streaming mode. + /// Verifies that ReturnTask mode emits a task lifecycle in streaming mode. /// [Fact] - public async Task ExecuteAsync_Streaming_WhenBackgroundResponsesAllowed_StreamsTaskUpdatesAsync() + public async Task ExecuteAsync_Streaming_WhenReturnTaskMode_StreamsTaskUpdatesAsync() { // Arrange AgentResponseUpdate[] updates = @@ -730,7 +732,7 @@ public async Task ExecuteAsync_Streaming_WhenBackgroundResponsesAllowed_StreamsT ]; A2AAgentHandler handler = CreateHandler( CreateStreamingAgentMock(updates), - runMode: AgentRunMode.AllowBackgroundIfSupported); + runMode: AgentRunMode.ReturnTask); // Act var events = await CollectEventsAsync(handler, new RequestContext @@ -782,7 +784,7 @@ public async Task ExecuteAsync_Streaming_WhenBackgroundResponsesAllowed_StreamsT /// Verifies that a non-immediate request aggregates all updates into a completed task. /// [Fact] - public async Task ExecuteAsync_WhenBackgroundResponsesAllowedAndReturnImmediatelyFalse_ReturnsCompletedTaskAsync() + public async Task ExecuteAsync_WhenReturnTaskModeAndReturnImmediatelyFalse_ReturnsCompletedTaskAsync() { // Arrange AgentResponseUpdate[] updates = @@ -792,7 +794,7 @@ public async Task ExecuteAsync_WhenBackgroundResponsesAllowedAndReturnImmediatel ]; A2AAgentHandler handler = CreateHandler( CreateStreamingAgentMock(updates), - runMode: AgentRunMode.AllowBackgroundIfSupported); + runMode: AgentRunMode.ReturnTask); // Act var events = await CollectEventsAsync(handler, new RequestContext @@ -831,7 +833,7 @@ public async Task ExecuteAsync_WhenAggregatingTaskUpdates_PreservesArtifactMetad ]; A2AAgentHandler handler = CreateHandler( CreateStreamingAgentMock(updates), - runMode: AgentRunMode.AllowBackgroundIfSupported); + runMode: AgentRunMode.ReturnTask); // Act var events = await CollectEventsAsync(handler, new RequestContext @@ -869,7 +871,7 @@ public async Task ExecuteAsync_WhenAggregatedTaskEmissionIsCanceled_CancelsTaskA ]; A2AAgentHandler handler = CreateHandler( CreateStreamingAgentMock(updates), - runMode: AgentRunMode.AllowBackgroundIfSupported); + runMode: AgentRunMode.ReturnTask); var events = new EventCollector(); var eventQueue = new AgentEventQueue(); var readerTask = ReadEventsAsync(eventQueue, events); @@ -917,7 +919,7 @@ public async Task ExecuteAsync_WhenAggregatedTaskEmissionFails_FailsTaskAsync() ]; A2AAgentHandler handler = CreateHandler( CreateStreamingAgentMock(updates), - runMode: AgentRunMode.AllowBackgroundIfSupported); + runMode: AgentRunMode.ReturnTask); // Act var events = await CollectEventsForThrowingExecuteAsync(handler, new RequestContext @@ -940,10 +942,10 @@ public async Task ExecuteAsync_WhenAggregatedTaskEmissionFails_FailsTaskAsync() } /// - /// Verifies that an immediate request returns one aggregated message when background responses are disabled. + /// Verifies that an immediate request returns one aggregated message in ReturnMessage mode. /// [Fact] - public async Task ExecuteAsync_WhenBackgroundResponsesDisallowedAndReturnImmediatelyTrue_ReturnsMessageAsync() + public async Task ExecuteAsync_WhenReturnMessageModeAndReturnImmediatelyTrue_ReturnsMessageAsync() { // Arrange AgentResponseUpdate[] updates = @@ -953,7 +955,7 @@ public async Task ExecuteAsync_WhenBackgroundResponsesDisallowedAndReturnImmedia ]; A2AAgentHandler handler = CreateHandler( CreateStreamingAgentMock(updates), - runMode: AgentRunMode.DisallowBackground); + runMode: AgentRunMode.ReturnMessage); // Act var events = await CollectEventsAsync(handler, new RequestContext @@ -973,10 +975,10 @@ public async Task ExecuteAsync_WhenBackgroundResponsesDisallowedAndReturnImmedia } /// - /// Verifies that a non-immediate request aggregates all updates into one message when background responses are disabled. + /// Verifies that a non-immediate request aggregates all updates into one message in ReturnMessage mode. /// [Fact] - public async Task ExecuteAsync_WhenBackgroundResponsesDisallowedAndReturnImmediatelyFalse_ReturnsMessageAsync() + public async Task ExecuteAsync_WhenReturnMessageModeAndReturnImmediatelyFalse_ReturnsMessageAsync() { // Arrange AgentResponseUpdate[] updates = @@ -986,7 +988,7 @@ public async Task ExecuteAsync_WhenBackgroundResponsesDisallowedAndReturnImmedia ]; A2AAgentHandler handler = CreateHandler( CreateStreamingAgentMock(updates), - runMode: AgentRunMode.DisallowBackground); + runMode: AgentRunMode.ReturnMessage); // Act var events = await CollectEventsAsync(handler, new RequestContext @@ -1021,7 +1023,7 @@ public async Task ExecuteAsync_Streaming_WithoutMessageId_ContinuesCurrentArtifa ]; A2AAgentHandler handler = CreateHandler( CreateStreamingAgentMock(updates), - runMode: AgentRunMode.AllowBackgroundIfSupported); + runMode: AgentRunMode.ReturnTask); // Act var events = await CollectEventsAsync(handler, new RequestContext @@ -1063,7 +1065,7 @@ public async Task ExecuteAsync_Streaming_WithoutMessageIds_StreamsSingleArtifact ]; A2AAgentHandler handler = CreateHandler( CreateStreamingAgentMock(updates), - runMode: AgentRunMode.AllowBackgroundIfSupported); + runMode: AgentRunMode.ReturnTask); // Act var events = await CollectEventsAsync(handler, new RequestContext @@ -1106,7 +1108,7 @@ public async Task ExecuteAsync_Streaming_WithEmptyMessageIds_StreamsSingleArtifa ]; A2AAgentHandler handler = CreateHandler( CreateStreamingAgentMock(updates), - runMode: AgentRunMode.AllowBackgroundIfSupported); + runMode: AgentRunMode.ReturnTask); // Act var events = await CollectEventsAsync(handler, new RequestContext @@ -1156,7 +1158,7 @@ public async Task ExecuteAsync_Streaming_WhenCancellationRequested_CancelsTaskAs ItExpr.IsAny(), ItExpr.IsAny()) .Returns(() => ToCancelingAsyncEnumerableAsync(cts)); - A2AAgentHandler handler = CreateHandler(agentMock, runMode: AgentRunMode.AllowBackgroundIfSupported); + A2AAgentHandler handler = CreateHandler(agentMock, runMode: AgentRunMode.ReturnTask); var events = new EventCollector(); var eventQueue = new AgentEventQueue(); var readerTask = ReadEventsAsync(eventQueue, events); @@ -1199,7 +1201,7 @@ public async Task ExecuteAsync_Streaming_WhenAgentThrows_FailsTaskAsync() CreateThrowingStreamingAgentMock( [new AgentResponseUpdate(ChatRole.Assistant, "chunk 1") { ResponseId = "r1", MessageId = "m1" }], new InvalidOperationException("Stream failed")), - runMode: AgentRunMode.AllowBackgroundIfSupported); + runMode: AgentRunMode.ReturnTask); // Act var events = await CollectEventsForThrowingExecuteAsync(handler, new RequestContext @@ -1245,7 +1247,7 @@ public async Task ExecuteAsync_Streaming_WhenMessageIdChanges_FinalizesPreviousA ]; A2AAgentHandler handler = CreateHandler( CreateThrowingStreamingAgentMock(updates, new InvalidOperationException("Stream failed")), - runMode: AgentRunMode.AllowBackgroundIfSupported); + runMode: AgentRunMode.ReturnTask); // Act var events = await CollectEventsForThrowingExecuteAsync(handler, new RequestContext @@ -1291,7 +1293,7 @@ public async Task ExecuteAsync_Streaming_WhenMessageIdReappears_UsesDistinctArti ]; A2AAgentHandler handler = CreateHandler( CreateStreamingAgentMock(updates), - runMode: AgentRunMode.AllowBackgroundIfSupported); + runMode: AgentRunMode.ReturnTask); // Act var events = await CollectEventsAsync(handler, new RequestContext @@ -1345,7 +1347,7 @@ public async Task ExecuteAsync_Streaming_WhenAgentThrowsOperationCanceledWithout // Arrange A2AAgentHandler handler = CreateHandler( CreateThrowingStreamingAgentMock([], new OperationCanceledException("Agent gave up")), - runMode: AgentRunMode.AllowBackgroundIfSupported); + runMode: AgentRunMode.ReturnTask); // Act var events = await CollectEventsForThrowingExecuteAsync(handler, new RequestContext @@ -1369,7 +1371,7 @@ public async Task ExecuteAsync_Streaming_WithNoUpdates_CompletesTaskAsync() // Arrange A2AAgentHandler handler = CreateHandler( CreateStreamingAgentMock([]), - runMode: AgentRunMode.AllowBackgroundIfSupported); + runMode: AgentRunMode.ReturnTask); // Act var events = await CollectEventsAsync(handler, new RequestContext @@ -1404,7 +1406,7 @@ public async Task ExecuteAsync_Streaming_WithOnlyContentlessUpdates_CompletesTas ]; A2AAgentHandler handler = CreateHandler( CreateStreamingAgentMock(updates), - runMode: AgentRunMode.AllowBackgroundIfSupported); + runMode: AgentRunMode.ReturnTask); // Act var events = await CollectEventsAsync(handler, new RequestContext @@ -1435,7 +1437,7 @@ public async Task ExecuteAsync_Streaming_WhenContentlessUpdateChangesMessageId_F ]; A2AAgentHandler handler = CreateHandler( CreateStreamingAgentMock(updates), - runMode: AgentRunMode.AllowBackgroundIfSupported); + runMode: AgentRunMode.ReturnTask); // Act var events = await CollectEventsAsync(handler, new RequestContext @@ -1500,10 +1502,10 @@ public async Task ExecuteAsync_Streaming_WithMetadata_PassesOptionsWithAdditiona } /// - /// Verifies that streaming mode passes null options when metadata is null. + /// Verifies that streaming mode passes empty options when metadata is null. /// [Fact] - public async Task ExecuteAsync_Streaming_WithNullMetadata_PassesNullOptionsAsync() + public async Task ExecuteAsync_Streaming_WithNullMetadata_PassesEmptyOptionsAsync() { // Arrange AgentRunOptions? capturedOptions = null; @@ -1522,7 +1524,9 @@ public async Task ExecuteAsync_Streaming_WithNullMetadata_PassesNullOptionsAsync // Assert Assert.True(optionsCaptured); - Assert.Null(capturedOptions); + Assert.NotNull(capturedOptions); + Assert.Null(capturedOptions.AllowBackgroundResponses); + Assert.Null(capturedOptions.AdditionalProperties); } /// @@ -2072,7 +2076,7 @@ public async Task Handler_WithNullSessionStore_SessionIsPersistedAcrossCallsAsyn } /// - /// Verifies that when the AllowBackgroundWhen delegate throws, the exception propagates + /// Verifies that when the ReturnTaskWhen delegate throws, the exception propagates /// and the agent is not invoked. /// [Fact] @@ -2082,7 +2086,7 @@ public async Task ExecuteAsync_DynamicMode_WhenCallbackThrows_PropagatesExceptio bool agentInvoked = false; A2AAgentHandler handler = CreateHandler( CreateAgentMock(_ => agentInvoked = true), - runMode: AgentRunMode.AllowBackgroundWhen((_, _) => + runMode: AgentRunMode.ReturnTaskWhen((_, _) => throw new InvalidOperationException("Callback failed"))); // Act & Assert @@ -2096,7 +2100,7 @@ await Assert.ThrowsAsync(() => } /// - /// Verifies that the CancellationToken is propagated to the AllowBackgroundWhen delegate. + /// Verifies that the CancellationToken is propagated to the ReturnTaskWhen delegate. /// [Fact] public async Task ExecuteAsync_DynamicMode_CancellationTokenIsPropagatedToCallbackAsync() @@ -2106,7 +2110,7 @@ public async Task ExecuteAsync_DynamicMode_CancellationTokenIsPropagatedToCallba using var cts = new CancellationTokenSource(); A2AAgentHandler handler = CreateHandler( CreateAgentMock(_ => { }), - runMode: AgentRunMode.AllowBackgroundWhen((_, ct) => + runMode: AgentRunMode.ReturnTaskWhen((_, ct) => { capturedToken = ct; return ValueTask.FromResult(false); @@ -2128,17 +2132,20 @@ await handler.ExecuteAsync( } /// - /// Verifies that the agent run mode is applied on the continuation/task-update path, - /// not just the new message path. + /// Verifies that the ReturnTaskWhen delegate is not invoked when updating an existing task. /// [Fact] - public async Task ExecuteAsync_OnContinuation_RunModeIsAppliedAsync() + public async Task ExecuteAsync_OnContinuation_DoesNotInvokeDynamicModeCallbackAsync() { // Arrange - AgentRunOptions? capturedOptions = null; + bool callbackInvoked = false; A2AAgentHandler handler = CreateHandler( - CreateAgentMock(options => capturedOptions = options), - runMode: AgentRunMode.AllowBackgroundIfSupported); + CreateAgentMock(_ => { }), + runMode: AgentRunMode.ReturnTaskWhen((_, _) => + { + callbackInvoked = true; + return ValueTask.FromResult(false); + })); // Act await InvokeExecuteAsync(handler, new RequestContext @@ -2147,13 +2154,11 @@ public async Task ExecuteAsync_OnContinuation_RunModeIsAppliedAsync() TaskId = "task-1", ContextId = "ctx-1", Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] }, - Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] } }); // Assert - Assert.NotNull(capturedOptions); - Assert.True(capturedOptions.AllowBackgroundResponses); + Assert.False(callbackInvoked); } /// @@ -2463,7 +2468,7 @@ private static A2AAgentHandler CreateHandler( AgentRunMode? runMode = null, AgentSessionStore? agentSessionStore = null) { - runMode ??= AgentRunMode.DisallowBackground; + runMode ??= AgentRunMode.ReturnMessage; var hostAgent = new AIHostAgent( innerAgent: agentMock.Object, diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs index df4669a014a..9d61932417e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs @@ -248,7 +248,7 @@ public void AddA2AServer_WithCustomOptions_Succeeds() IChatClient mockChatClient = new DummyChatClient(); builder.Services.AddKeyedSingleton("chat-client", mockChatClient); IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - agentBuilder.AddA2AServer(options => options.AgentRunMode = AgentRunMode.AllowBackgroundIfSupported); + agentBuilder.AddA2AServer(options => options.AgentRunMode = AgentRunMode.ReturnTask); builder.Services.AddLogging(); using WebApplication app = builder.Build(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs index 40905bd6ecb..ce310dbec84 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs @@ -249,7 +249,7 @@ public async Task AddA2AServer_WithConfigureOptions_InvokesCallbackAsync() services.AddA2AServer(AgentName, options => { callbackInvoked = true; - options.AgentRunMode = AgentRunMode.AllowBackgroundIfSupported; + options.AgentRunMode = AgentRunMode.ReturnTask; }); // Assert - callback is invoked during resolution @@ -510,7 +510,7 @@ public async Task AddA2AServer_WithBackgroundResponsesAndNonImmediateRequest_Ret services.AddKeyedSingleton(AgentName, (_, _) => agentMock.Object); services.AddA2AServer( AgentName, - options => options.AgentRunMode = AgentRunMode.AllowBackgroundIfSupported); + options => options.AgentRunMode = AgentRunMode.ReturnTask); await using var provider = services.BuildServiceProvider(); var server = provider.GetRequiredKeyedService(AgentName); SendMessageRequest request = CreateTestSendMessageRequest(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AgentRunModeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AgentRunModeTests.cs index cbe1254b81c..b9c5e5920ac 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AgentRunModeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AgentRunModeTests.cs @@ -12,25 +12,25 @@ namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests; public sealed class AgentRunModeTests { /// - /// Verifies that AllowBackgroundWhen throws ArgumentNullException for null delegate. + /// Verifies that ReturnTaskWhen throws ArgumentNullException for null delegate. /// [Fact] - public void AllowBackgroundWhen_NullDelegate_ThrowsArgumentNullException() + public void ReturnTaskWhen_NullDelegate_ThrowsArgumentNullException() { // Arrange & Act & Assert Assert.Throws(() => - AgentRunMode.AllowBackgroundWhen(null!)); + AgentRunMode.ReturnTaskWhen(null!)); } /// - /// Verifies that DisallowBackground equals another DisallowBackground instance. + /// Verifies that ReturnMessage equals another ReturnMessage instance. /// [Fact] - public void Equals_DisallowBackground_AreEqual() + public void Equals_ReturnMessage_AreEqual() { // Arrange - var mode1 = AgentRunMode.DisallowBackground; - var mode2 = AgentRunMode.DisallowBackground; + var mode1 = AgentRunMode.ReturnMessage; + var mode2 = AgentRunMode.ReturnMessage; // Act & Assert Assert.True(mode1.Equals(mode2)); @@ -40,14 +40,14 @@ public void Equals_DisallowBackground_AreEqual() } /// - /// Verifies that AllowBackgroundIfSupported equals another AllowBackgroundIfSupported instance. + /// Verifies that ReturnTask equals another ReturnTask instance. /// [Fact] - public void Equals_AllowBackgroundIfSupported_AreEqual() + public void Equals_ReturnTask_AreEqual() { // Arrange - var mode1 = AgentRunMode.AllowBackgroundIfSupported; - var mode2 = AgentRunMode.AllowBackgroundIfSupported; + var mode1 = AgentRunMode.ReturnTask; + var mode2 = AgentRunMode.ReturnTask; // Act & Assert Assert.True(mode1.Equals(mode2)); @@ -55,19 +55,18 @@ public void Equals_AllowBackgroundIfSupported_AreEqual() } /// - /// Verifies that DisallowBackground and AllowBackgroundIfSupported are not equal. + /// Verifies that ReturnMessage and ReturnTask are not equal. /// [Fact] public void Equals_DifferentModes_AreNotEqual() { // Arrange - var disallow = AgentRunMode.DisallowBackground; - var allow = AgentRunMode.AllowBackgroundIfSupported; + var message = AgentRunMode.ReturnMessage; + var task = AgentRunMode.ReturnTask; // Act & Assert - Assert.False(disallow.Equals(allow)); - Assert.False(disallow == allow); - Assert.True(disallow != allow); + Assert.False(message.Equals(task)); + Assert.False(message == task); } /// @@ -77,7 +76,7 @@ public void Equals_DifferentModes_AreNotEqual() public void Equals_Null_ReturnsFalse() { // Arrange - var mode = AgentRunMode.DisallowBackground; + var mode = AgentRunMode.ReturnMessage; // Act & Assert Assert.False(mode.Equals(null)); @@ -108,9 +107,9 @@ public void Equals_BothNull_AreEqual() public void ToString_ReturnsExpectedValues() { // Act & Assert - Assert.Equal("message", AgentRunMode.DisallowBackground.ToString()); - Assert.Equal("task", AgentRunMode.AllowBackgroundIfSupported.ToString()); - Assert.Equal("dynamic", AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(true)).ToString()); + Assert.Equal("message", AgentRunMode.ReturnMessage.ToString()); + Assert.Equal("task", AgentRunMode.ReturnTask.ToString()); + Assert.Equal("dynamic", AgentRunMode.ReturnTaskWhen((_, _) => ValueTask.FromResult(true)).ToString()); } /// @@ -120,24 +119,24 @@ public void ToString_ReturnsExpectedValues() public void Equals_WithObjectParameter_WorksCorrectly() { // Arrange - var mode = AgentRunMode.DisallowBackground; + var mode = AgentRunMode.ReturnMessage; // Act & Assert - Assert.True(mode.Equals((object)AgentRunMode.DisallowBackground)); - Assert.False(mode.Equals((object)AgentRunMode.AllowBackgroundIfSupported)); + Assert.True(mode.Equals((object)AgentRunMode.ReturnMessage)); + Assert.False(mode.Equals((object)AgentRunMode.ReturnTask)); Assert.False(mode.Equals("not a run mode")); } /// - /// Verifies that two AllowBackgroundWhen instances with different delegates are not considered equal, + /// Verifies that two ReturnTaskWhen instances with different delegates are not considered equal, /// because equality includes delegate identity for dynamic modes. /// [Fact] - public void Equals_AllowBackgroundWhen_DifferentDelegates_AreNotEqual() + public void Equals_ReturnTaskWhen_DifferentDelegates_AreNotEqual() { // Arrange - var mode1 = AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(true)); - var mode2 = AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(false)); + var mode1 = AgentRunMode.ReturnTaskWhen((_, _) => ValueTask.FromResult(true)); + var mode2 = AgentRunMode.ReturnTaskWhen((_, _) => ValueTask.FromResult(false)); // Act & Assert Assert.False(mode1.Equals(mode2)); @@ -145,15 +144,15 @@ public void Equals_AllowBackgroundWhen_DifferentDelegates_AreNotEqual() } /// - /// Verifies that two AllowBackgroundWhen instances with the same delegate are considered equal. + /// Verifies that two ReturnTaskWhen instances with the same delegate are considered equal. /// [Fact] - public void Equals_AllowBackgroundWhen_SameDelegate_AreEqual() + public void Equals_ReturnTaskWhen_SameDelegate_AreEqual() { // Arrange static ValueTask CallbackAsync(A2ARunDecisionContext _, CancellationToken __) => ValueTask.FromResult(true); - var mode1 = AgentRunMode.AllowBackgroundWhen(CallbackAsync); - var mode2 = AgentRunMode.AllowBackgroundWhen(CallbackAsync); + var mode1 = AgentRunMode.ReturnTaskWhen(CallbackAsync); + var mode2 = AgentRunMode.ReturnTaskWhen(CallbackAsync); // Act & Assert Assert.True(mode1.Equals(mode2)); From f4490f4ba198608f9085889afa07160bff25f279 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 4 Sep 2026 13:58:29 +0000 Subject: [PATCH 30/85] ci: adds additional dependabot groups in dotnet to reduce noise (#8059) --- .github/dependabot.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d3f94de97ad..c10d36de16d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -16,6 +16,22 @@ updates: patterns: - "AgentMemory" - "AgentMemory.AgentFramework" + aspire: + patterns: + - "Aspire.*" + - "CommunityToolkit.Aspire.*" + azure-ai-agentserver: + patterns: + - "Azure.AI.AgentServer.*" + agui: + patterns: + - "AGUI.*" + communitytoolkit-vectordata: + patterns: + - "CommunityToolkit.VectorData.*" + anthropic: + patterns: + - "Anthropic*" ignore: # For all System.* and Microsoft.Extensions/Bcl.* packages, ignore all major version updates - dependency-name: "System.*" From 9504805c91506ae17f9ee267579a6cb10b664fb6 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 4 Sep 2026 13:58:40 +0000 Subject: [PATCH 31/85] ci: adds a codeql actions group to the dependabot configuration (#8070) --- .github/dependabot.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c10d36de16d..051950f0641 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -83,6 +83,10 @@ updates: directories: - "/" - "/.github/actions/*" + groups: + codeql-actions: + patterns: + - "github/codeql-action/*" schedule: interval: "weekly" day: "sunday" From bea65abced28757462ef715f7ec9b342765cbaa2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:07:18 +0000 Subject: [PATCH 32/85] Chore(deps): Bump softprops/action-gh-release from 3.0.1 to 3.0.2 (#8073) Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 3.0.1 to 3.0.2. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/718ea10b132b3b2eba29c1007bb80653f286566b...3d0d9888cb7fd7b750713d6e236d1fcb99157228) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-version: 3.0.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/python-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index 43b4452a9f3..3819e0c9e8b 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -125,7 +125,7 @@ jobs: uv run poe --directory "packages/$PACKAGE" build fi - name: Release - uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: files: | python/dist/* From 13c51d500ed670b710777e129f1cd028dd9fbcdc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:07:42 +0000 Subject: [PATCH 33/85] Chore(deps): Bump dorny/paths-filter from 4.0.2 to 4.0.3 (#8072) Bumps [dorny/paths-filter](https://github.com/dorny/paths-filter) from 4.0.2 to 4.0.3. - [Release notes](https://github.com/dorny/paths-filter/releases) - [Changelog](https://github.com/dorny/paths-filter/blob/master/CHANGELOG.md) - [Commits](https://github.com/dorny/paths-filter/compare/7b450fff21473bca461d4b92ce414b9d0420d706...ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d) --- updated-dependencies: - dependency-name: dorny/paths-filter dependency-version: 4.0.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/dotnet-build-and-test.yml | 2 +- .github/workflows/python-lab-tests.yml | 2 +- .github/workflows/python-merge-tests.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/dotnet-build-and-test.yml b/.github/workflows/dotnet-build-and-test.yml index 920cce62a47..ed641439178 100644 --- a/.github/workflows/dotnet-build-and-test.yml +++ b/.github/workflows/dotnet-build-and-test.yml @@ -41,7 +41,7 @@ jobs: coreChanged: ${{ steps.filter.outputs.core }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 + - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: filter with: filters: | diff --git a/.github/workflows/python-lab-tests.yml b/.github/workflows/python-lab-tests.yml index 5f90e6a9aef..9a0034ab8e8 100644 --- a/.github/workflows/python-lab-tests.yml +++ b/.github/workflows/python-lab-tests.yml @@ -25,7 +25,7 @@ jobs: pythonChanges: ${{ steps.filter.outputs.python}} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 + - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: filter with: filters: | diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml index 17b338ab81b..4d227f4346b 100644 --- a/.github/workflows/python-merge-tests.yml +++ b/.github/workflows/python-merge-tests.yml @@ -42,7 +42,7 @@ jobs: githubCopilotChanged: ${{ steps.filter.outputs.github_copilot }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 + - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: filter with: filters: | From 0d70d4bc4823bdf5e3d10ce0f1aa7bb6c29d644c Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 4 Sep 2026 14:17:47 +0000 Subject: [PATCH 34/85] ci: switches to dedicated identity for promotion PRs (#8043) * ci: switches to dedicated identity for promotion PRs Signed-off-by: Vincent Biret * security: trims permissions because we're using a dedicate identity now Signed-off-by: Vincent Biret * ci: aligns tasks permissions with application declaration Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * ci: use app bot identity for API promotion commits Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --------- Signed-off-by: Vincent Biret Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- .github/workflows/promote-shipped-apis.yml | 32 +++++++++++++++++----- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/.github/workflows/promote-shipped-apis.yml b/.github/workflows/promote-shipped-apis.yml index 52a20104473..fb4d44ffe41 100644 --- a/.github/workflows/promote-shipped-apis.yml +++ b/.github/workflows/promote-shipped-apis.yml @@ -7,8 +7,7 @@ on: workflow_dispatch: permissions: - contents: write - pull-requests: write + contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -19,23 +18,42 @@ jobs: runs-on: ubuntu-latest steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.GHA_TOKEN_PROVIDER_APP_ID }} + private-key: ${{ secrets.GHA_TOKEN_PROVIDER_PEM }} + permission-contents: write + permission-pull-requests: write + + - name: Get GitHub App user ID + id: get-user-id + shell: bash + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT" + - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ steps.app-token.outputs.token }} - name: Configure git shell: pwsh + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config user.name "${{ steps.app-token.outputs.app-slug }}[bot]" + git config user.email "${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com" + git config --global url."https://$($env:GH_TOKEN)@github.com/".insteadOf "https://github.com/" - name: Check for existing PR id: check_pr shell: pwsh env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | $branch = "${{ github.ref_name }}" $prBranch = "promote-shipped-apis-$branch" @@ -95,7 +113,7 @@ jobs: if: steps.check_pr.outputs.pr_exists == 'false' && steps.check_changes.outputs.has_changes == 'true' shell: pwsh env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | $branch = "${{ github.ref_name }}" $prBranch = "promote-shipped-apis-$branch" From e80037b0f181a7ee0a2641aa837ab0c3f6edd3b4 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 4 Sep 2026 16:28:03 +0000 Subject: [PATCH 35/85] ci: adds a group for artifact actions (#8074) --- .github/dependabot.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 051950f0641..27995ed60da 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -87,6 +87,10 @@ updates: codeql-actions: patterns: - "github/codeql-action/*" + artifact-actions: + patterns: + - "actions/upload-artifact" + - "actions/download-artifact" schedule: interval: "weekly" day: "sunday" From 905c9b06c6f8df2fe430ff87837500be098884bb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:40:06 +0000 Subject: [PATCH 36/85] Chore(deps): Bump actions/setup-python from 5.6.0 to 7.0.0 (#8077) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5.6.0 to 7.0.0. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/a26af69be951a213d495a4c3e4e4022e16d87065...5fda3b95a4ea91299a34e894583c3862153e4b97) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/devflow-pr-review.yml | 2 +- .github/workflows/github-automation-tests.yml | 2 +- .github/workflows/issue-triage.yml | 2 +- .github/workflows/stale-issue-pr-ping.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/devflow-pr-review.yml b/.github/workflows/devflow-pr-review.yml index 48a2b7acefa..592ef3caa34 100644 --- a/.github/workflows/devflow-pr-review.yml +++ b/.github/workflows/devflow-pr-review.yml @@ -241,7 +241,7 @@ jobs: path: devflow - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" diff --git a/.github/workflows/github-automation-tests.yml b/.github/workflows/github-automation-tests.yml index a951455398c..a1597f7b0e6 100644 --- a/.github/workflows/github-automation-tests.yml +++ b/.github/workflows/github-automation-tests.yml @@ -31,7 +31,7 @@ jobs: with: node-version: "22" - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.11" diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml index 561ff4d0ea3..0e8a70fe401 100644 --- a/.github/workflows/issue-triage.yml +++ b/.github/workflows/issue-triage.yml @@ -185,7 +185,7 @@ jobs: path: devflow - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" diff --git a/.github/workflows/stale-issue-pr-ping.yml b/.github/workflows/stale-issue-pr-ping.yml index 4ce2546cea7..ce983390757 100644 --- a/.github/workflows/stale-issue-pr-ping.yml +++ b/.github/workflows/stale-issue-pr-ping.yml @@ -50,7 +50,7 @@ jobs: repository: ${{ github.repository }} fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }} - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.13' From 6d634132f206a4602e7318586c4776cbd68d9c31 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:42:18 +0000 Subject: [PATCH 37/85] Chore(deps): Bump github/codeql-action/autobuild (#8076) Bumps the codeql-actions group with 1 update in the / directory: [github/codeql-action/autobuild](https://github.com/github/codeql-action). Updates `github/codeql-action/autobuild` from 4.37.0 to 4.37.9 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/99df26d4f13ea111d4ec1a7dddef6063f76b97e9...cdf488f595d80d6e07e03d4674febd5ab45fa938) --- updated-dependencies: - dependency-name: github/codeql-action/autobuild dependency-version: 4.37.9 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: codeql-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 418b6fca3dd..62245624129 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -51,7 +51,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/autobuild@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun From cc8c1fa0a4c718a4a4cdbcee3340f0bf01746006 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=87=E5=AE=99=E7=9A=84=E5=B1=B1=E5=B3=B0?= Date: Fri, 4 Sep 2026 19:05:47 +0000 Subject: [PATCH 38/85] .NET: Clarify declarative workflow input serialization (#8046) * docs: clarify declarative workflow input serialization * docs: add workflows namespace to serialization snippet --- .../Declarative/AotCheckpointing/Program.cs | 22 ++++++--- .../Declarative/AotCheckpointing/README.md | 45 +++++++++++++++++-- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/dotnet/samples/03-workflows/Declarative/AotCheckpointing/Program.cs b/dotnet/samples/03-workflows/Declarative/AotCheckpointing/Program.cs index 2bfd6d51d72..8f515b7d866 100644 --- a/dotnet/samples/03-workflows/Declarative/AotCheckpointing/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/AotCheckpointing/Program.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Text.Json; +using System.Text.Json.Serialization; using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Azure.Identity; @@ -15,12 +17,12 @@ namespace Demo.Workflows.Declarative.AotCheckpointing; /// /// Demonstrates JSON checkpointing of a declarative workflow under reflection-disabled -/// (the AOT / trim-aggressive constraint set +/// (the AOT / trim-aggressive constraint set /// via JsonSerializerIsReflectionEnabledByDefault=false in the csproj). /// /// -/// The key call is -/// with . Drop the options argument to observe the AOT failure. See README. +/// The key call is with +/// . Drop the options argument to observe the AOT failure. See README. /// internal sealed class Program { @@ -31,14 +33,14 @@ public static async Task Main(string[] args) await CreateGreeterAgentAsync(foundryEndpoint, configuration); - string workflowInput = Application.GetInput(args); + WorkflowInput workflowInput = new(Application.GetInput(args)); Workflow CreateWorkflow() { AzureAgentProvider agentProvider = new(foundryEndpoint, new AzureCliCredential()); DeclarativeWorkflowOptions options = new(agentProvider) { Configuration = configuration }; string workflowPath = Path.Combine(AppContext.BaseDirectory, "AotCheckpointing.yaml"); - return DeclarativeWorkflowBuilder.Build(workflowPath, options); + return DeclarativeWorkflowBuilder.Build(workflowPath, options, TransformInput); } DirectoryInfo checkpointFolder = Directory.CreateDirectory(Path.Combine(".", $"chk-{DateTime.Now:yyMMdd-HHmmss-ff}")); @@ -74,7 +76,10 @@ Workflow CreateWorkflow() } } - private static async Task> RunAndStreamAsync(Workflow workflow, string input, CheckpointManager checkpointManager) + private static ChatMessage TransformInput(WorkflowInput input) => + new(ChatRole.User, JsonSerializer.Serialize(input, AotCheckpointingJsonContext.Default.WorkflowInput)); + + private static async Task> RunAndStreamAsync(Workflow workflow, WorkflowInput input, CheckpointManager checkpointManager) { StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, input, checkpointManager).ConfigureAwait(false); return await DrainAsync(run).ConfigureAwait(false); @@ -172,3 +177,8 @@ private static void TryDelete(DirectoryInfo directory) } } } + +internal sealed record WorkflowInput(string Message); + +[JsonSerializable(typeof(WorkflowInput))] +internal sealed partial class AotCheckpointingJsonContext : JsonSerializerContext; diff --git a/dotnet/samples/03-workflows/Declarative/AotCheckpointing/README.md b/dotnet/samples/03-workflows/Declarative/AotCheckpointing/README.md index 9856274bfc8..b601f40299d 100644 --- a/dotnet/samples/03-workflows/Declarative/AotCheckpointing/README.md +++ b/dotnet/samples/03-workflows/Declarative/AotCheckpointing/README.md @@ -25,15 +25,54 @@ reflection-disabled `System.Text.Json` -- the same constraint imposed by return is the proof JSON **reads** round-trip too. The resumed run is disposed immediately; without a pending external request it would park in `WaitForInputAsync` indefinitely. +- The initial workflow input is a `WorkflowInput` record. `TransformInput` uses the + source-generated `AotCheckpointingJsonContext` to serialize it into a + `ChatMessage` before the declarative workflow runs. `DeclarativeWorkflowJsonOptions` is marked `[Experimental("MAAI001")]`. Suppress that diagnostic in your csproj to use it. -### Registering user-defined types +### Initial input and checkpoint serialization are separate -For workflows whose inputs or custom `ActionExecutorResult.Result` -payloads are user-defined, clone `Default` and append your own resolver: +`DeclarativeWorkflowBuilder.Build` accepts an optional `inputTransform` delegate. +For a non-`ChatMessage` input, the default behavior is to call `ToString()`; the +checkpoint serializer is not involved in this conversion. Use a source-generated +context (or your own `JsonSerializerOptions`) in the delegate when the workflow +should receive a JSON representation of a typed input: + +```csharp +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Agents.AI.Workflows.Declarative; +using Microsoft.Extensions.AI; + +internal sealed record WorkflowInput(string Message); + +[JsonSerializable(typeof(WorkflowInput))] +internal sealed partial class AppJsonContext : JsonSerializerContext; + +Workflow workflow = DeclarativeWorkflowBuilder.Build( + workflowPath, + options, + input => new ChatMessage( + ChatRole.User, + JsonSerializer.Serialize(input, AppJsonContext.Default.WorkflowInput))); +``` + +This sample uses `AotCheckpointingJsonContext` for that initial-input transform. +The separate `DeclarativeWorkflowJsonOptions.Default` passed to +`CheckpointManager.CreateJson` supplies type information for declarative workflow +checkpoint state. If checkpoint state also contains application-defined payloads, +clone those options and append the application's resolver as shown below; adding +the resolver to checkpoint options does not automatically change the initial input. + +### Registering user-defined checkpoint types + +For custom `ActionExecutorResult.Result` payloads or other user-defined values +that are persisted in workflow checkpoint state, clone `Default` and append your +own resolver: ```csharp JsonSerializerOptions options = new(DeclarativeWorkflowJsonOptions.Default); From 2c49f50cf08ebb6c1687146336f039051f159333 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:08:04 +0000 Subject: [PATCH 39/85] .NET: Preserve streamed annotations in Foundry hosted responses (#7984) * .NET: Preserve streamed response annotations Capture annotations delivered separately from text and emit supported citation types through hosted Responses streams. Add local and live streaming and non-streaming coverage for web search and Azure AI Search. Copilot-Session: 0c67da26-d12e-47ab-aac4-53e251ef6edf * .NET: Refine streamed annotation correlation Copilot-Session: 0c67da26-d12e-47ab-aac4-53e251ef6edf * .NET: Align annotation event with OpenAI 2.12 Use the renamed output text annotation event so item correlation remains available after the SDK upgrade. Copilot-Session: 0c67da26-d12e-47ab-aac4-53e251ef6edf * .NET: Update annotation integration tests for SDK changes Use the renamed strongly typed annotation event and unwrap project connection results after the Projects 3 upgrade. Copilot-Session: 0c67da26-d12e-47ab-aac4-53e251ef6edf * .NET: Use hosted Search index in CI Use the dedicated hosted-agent Search index configuration for Foundry hosted integration tests. Copilot-Session: 0c67da26-d12e-47ab-aac4-53e251ef6edf --------- Copilot-Session: 0c67da26-d12e-47ab-aac4-53e251ef6edf --- .github/workflows/dotnet-build-and-test.yml | 7 +- .../OutputConverter.cs | 128 ++++- .../Shared/IntegrationTests/TestSettings.cs | 2 + .../Program.cs | 68 ++- ...reSearchToolAnnotationsHostedAgentTests.cs | 110 ++++ ...SearchToolAnnotationsHostedAgentFixture.cs | 29 + .../WebSearchAnnotationsHostedAgentFixture.cs | 12 + .../README.md | 50 +- .../WebSearchAnnotationsHostedAgentTests.cs | 108 ++++ .../scripts/it-bootstrap-agents.ps1 | 2 + .../AgentFrameworkResponseHandlerTests.cs | 44 ++ ...Agents.AI.Foundry.Hosting.UnitTests.csproj | 1 + .../OutputConverterTests.cs | 501 ++++++++++++++++++ .../ServiceCollectionExtensionsTests.cs | 216 ++++++++ 14 files changed, 1248 insertions(+), 30 deletions(-) create mode 100644 dotnet/tests/Foundry.Hosting.IntegrationTests/AzureSearchToolAnnotationsHostedAgentTests.cs create mode 100644 dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/AzureSearchToolAnnotationsHostedAgentFixture.cs create mode 100644 dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/WebSearchAnnotationsHostedAgentFixture.cs create mode 100644 dotnet/tests/Foundry.Hosting.IntegrationTests/WebSearchAnnotationsHostedAgentTests.cs diff --git a/.github/workflows/dotnet-build-and-test.yml b/.github/workflows/dotnet-build-and-test.yml index ed641439178..5a8e21a68ad 100644 --- a/.github/workflows/dotnet-build-and-test.yml +++ b/.github/workflows/dotnet-build-and-test.yml @@ -600,12 +600,9 @@ jobs: env: AZURE_AI_PROJECT_ENDPOINT: ${{ vars.IT_HOSTED_AGENT_PROJECT_ENDPOINT }} AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.IT_HOSTED_AGENT_MODEL_DEPLOYMENT_NAME }} - # Azure AI Search (for the azure-search-rag scenario). Reuses the integration - # environment secrets shared with python-sample-validation.yml. The index is - # provisioned out of band; see dotnet/tests/Foundry.Hosting.IntegrationTests/README.md - # for the required schema and seed content. + # Use the dedicated hosted-agent Search index for both Search scenarios. AZURE_SEARCH_ENDPOINT: ${{ secrets.AZURE_SEARCH_ENDPOINT }} - AZURE_SEARCH_INDEX_NAME: ${{ secrets.AZURE_SEARCH_INDEX_NAME }} + AZURE_SEARCH_INDEX_NAME: ${{ vars.FOUNDRY_HOSTED_AGENT_SEARCH_INDEX_NAME }} # IT_HOSTED_AGENT_IMAGE was exported into $GITHUB_ENV by the previous step. # This final job is required to satisfy the merge queue. It must only run (or succeed) if no tests failed diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs index 27be4dcdea4..e7506da5f1e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs @@ -15,6 +15,14 @@ using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; using MeaiTextContent = Microsoft.Extensions.AI.TextContent; +using OpenAIContainerFileCitationMessageAnnotation = OpenAI.Responses.ContainerFileCitationMessageAnnotation; +using OpenAIFileCitationMessageAnnotation = OpenAI.Responses.FileCitationMessageAnnotation; +using OpenAIFilePathMessageAnnotation = OpenAI.Responses.FilePathMessageAnnotation; +using OpenAIMessageResponseItem = OpenAI.Responses.MessageResponseItem; +using OpenAIStreamingResponseOutputItemAddedUpdate = OpenAI.Responses.StreamingResponseOutputItemAddedUpdate; +using OpenAIStreamingResponseOutputItemDoneUpdate = OpenAI.Responses.StreamingResponseOutputItemDoneUpdate; +using OpenAIStreamingResponseOutputTextAnnotationAddedUpdate = OpenAI.Responses.StreamingResponseOutputTextAnnotationAddedUpdate; +using OpenAIStreamingResponseOutputTextDeltaUpdate = OpenAI.Responses.StreamingResponseOutputTextDeltaUpdate; namespace Microsoft.Agents.AI.Foundry.Hosting; @@ -100,13 +108,14 @@ public static async IAsyncEnumerable ConvertUpdatesToEvents continue; } + string? currentMessageId = ResolveMessageId(update); foreach (var content in update.Contents) { switch (content) { case MeaiTextContent textContent: { - if (!IsSameMessage(update.MessageId, previousMessageId) && currentMessageBuilder is not null) + if (!IsSameMessage(currentMessageId, previousMessageId) && currentMessageBuilder is not null) { foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText, accumulatedAnnotations)) { @@ -119,7 +128,10 @@ public static async IAsyncEnumerable ConvertUpdatesToEvents accumulatedAnnotations = null; } - previousMessageId = update.MessageId; + if (currentMessageId is { Length: > 0 }) + { + previousMessageId = currentMessageId; + } if (currentMessageBuilder is null) { @@ -138,14 +150,6 @@ public static async IAsyncEnumerable ConvertUpdatesToEvents yield return currentTextBuilder!.EmitDelta(textContent.Text); } - if (textContent.Annotations is { Count: > 0 }) - { - foreach (var sdkAnnotation in ConvertToSdkAnnotations(textContent.Annotations)) - { - (accumulatedAnnotations ??= []).Add(sdkAnnotation); - } - } - break; } @@ -336,6 +340,27 @@ public static async IAsyncEnumerable ConvertUpdatesToEvents default: break; } + + var isTextContent = content is MeaiTextContent; + var isAnnotationOnlyContentForCurrentMessage = + content.GetType() == typeof(AIContent) && + IsSameAnnotationMessage(currentMessageId, previousMessageId); + + // MEAI OpenAI sends streaming citations in a separate annotation-only AIContent after + // the text deltas. Accumulate them because AgentServer emits annotations only after + // output_text.done, and de-duplicate providers that report the same citation twice. + if ((isTextContent || isAnnotationOnlyContentForCurrentMessage) + && content.Annotations is { Count: > 0 } + && currentMessageBuilder is not null) + { + foreach (var sdkAnnotation in ConvertToSdkAnnotations(content.Annotations)) + { + if (accumulatedAnnotations?.Any(existing => AreEquivalentAnnotations(existing, sdkAnnotation)) is not true) + { + (accumulatedAnnotations ??= []).Add(sdkAnnotation); + } + } + } } } @@ -385,16 +410,93 @@ private static IEnumerable CloseCurrentMessage( private static bool IsSameMessage(string? currentId, string? previousId) => currentId is not { Length: > 0 } || previousId is not { Length: > 0 } || currentId == previousId; + private static bool IsSameAnnotationMessage(string? currentId, string? previousId) => + currentId is { Length: > 0 } + ? currentId == previousId + : previousId is not { Length: > 0 }; + + private static string? ResolveMessageId(AgentResponseUpdate update) + { + if (update.MessageId is { Length: > 0 }) + { + return update.MessageId; + } + + object? rawRepresentation = update.RawRepresentation is ChatResponseUpdate chatUpdate + ? chatUpdate.RawRepresentation + : update.RawRepresentation; + + return rawRepresentation switch + { + OpenAIStreamingResponseOutputTextAnnotationAddedUpdate annotationAdded => annotationAdded.ItemId, + OpenAIStreamingResponseOutputTextDeltaUpdate textDelta => textDelta.ItemId, + OpenAIStreamingResponseOutputItemAddedUpdate { Item: OpenAIMessageResponseItem message } => message.Id, + OpenAIStreamingResponseOutputItemDoneUpdate { Item: OpenAIMessageResponseItem message } => message.Id, + _ => null, + }; + } + + private static bool AreEquivalentAnnotations(Annotation left, Annotation right) => + (left, right) switch + { + (UrlCitationBody leftUrl, UrlCitationBody rightUrl) => + leftUrl.Url == rightUrl.Url && + leftUrl.StartIndex == rightUrl.StartIndex && + leftUrl.EndIndex == rightUrl.EndIndex && + leftUrl.Title == rightUrl.Title, + (FileCitationBody leftFile, FileCitationBody rightFile) => + leftFile.FileId == rightFile.FileId && + leftFile.Index == rightFile.Index && + leftFile.Filename == rightFile.Filename, + (ContainerFileCitationBody leftContainerFile, ContainerFileCitationBody rightContainerFile) => + leftContainerFile.ContainerId == rightContainerFile.ContainerId && + leftContainerFile.FileId == rightContainerFile.FileId && + leftContainerFile.StartIndex == rightContainerFile.StartIndex && + leftContainerFile.EndIndex == rightContainerFile.EndIndex && + leftContainerFile.Filename == rightContainerFile.Filename, + (FilePath leftFilePath, FilePath rightFilePath) => + leftFilePath.FileId == rightFilePath.FileId && + leftFilePath.Index == rightFilePath.Index, + _ => false, + }; + /// /// Converts MEAI instances to Responses SDK objects. - /// Only with a URL and at least one - /// with explicit start/end indices is converted; all other shapes are skipped. + /// Only supported shapes are converted; all others are skipped. /// private static IEnumerable ConvertToSdkAnnotations(IList annotations) { foreach (var ann in annotations) { - if (ann is not CitationAnnotation citation || citation.Url is null) + if (ann is not CitationAnnotation citation) + { + continue; + } + + if (citation.RawRepresentation is OpenAIContainerFileCitationMessageAnnotation containerFileCitation) + { + yield return new ContainerFileCitationBody( + containerFileCitation.ContainerId, + containerFileCitation.FileId, + containerFileCitation.StartIndex, + containerFileCitation.EndIndex, + containerFileCitation.Filename); + continue; + } + + if (citation.RawRepresentation is OpenAIFileCitationMessageAnnotation fileCitation) + { + yield return new FileCitationBody(fileCitation.FileId, fileCitation.Index, fileCitation.Filename); + continue; + } + + if (citation.RawRepresentation is OpenAIFilePathMessageAnnotation filePath) + { + yield return new FilePath(filePath.FileId, filePath.Index); + continue; + } + + if (citation.Url is null) { continue; } diff --git a/dotnet/src/Shared/IntegrationTests/TestSettings.cs b/dotnet/src/Shared/IntegrationTests/TestSettings.cs index c2a7ab09730..ca830575fa4 100644 --- a/dotnet/src/Shared/IntegrationTests/TestSettings.cs +++ b/dotnet/src/Shared/IntegrationTests/TestSettings.cs @@ -22,6 +22,8 @@ internal static class TestSettings public const string AzureAIProjectEndpoint = "AZURE_AI_PROJECT_ENDPOINT"; // Azure AI Search (Foundry.Hosting integration tests, RAG scenario) + public const string AzureSearchConnectionId = "AZURE_SEARCH_CONNECTION_ID"; + public const string AzureSearchConnectionName = "AZURE_SEARCH_CONNECTION_NAME"; public const string AzureSearchEndpoint = "AZURE_SEARCH_ENDPOINT"; public const string AzureSearchIndexName = "AZURE_SEARCH_INDEX_NAME"; diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs index 087e9e81f43..0520bce3859 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs @@ -3,6 +3,7 @@ using System.ComponentModel; using Azure; using Azure.AI.Projects; +using Azure.AI.Projects.Agents; using Azure.Identity; using Azure.Search.Documents; using Azure.Search.Documents.Models; @@ -43,6 +44,8 @@ "custom-storage" => CreateCustomStorageAgent(projectClient, deployment), "memory" => await CreateMemoryAgentAsync(projectClient, deployment).ConfigureAwait(false), "azure-search-rag" => CreateAzureSearchRagAgent(projectClient, deployment), + "azure-search-tool-annotations" => CreateAzureSearchToolAnnotationsAgent(projectClient, deployment), + "web-search-annotations" => CreateWebSearchAnnotationsAgent(projectClient, deployment), "session-files" => CreateSessionFilesAgent(projectClient, deployment), "agent-skills" => CreateAgentSkillsAgent(projectClient, deployment), "user-identity" => CreateUserIdentityAgent(projectClient, deployment), @@ -77,14 +80,12 @@ options.SteerableConversations = scenario == "steerable-long-running"; }); -// toolbox-oauth-consent scenario: pre-register a Foundry toolbox whose tool source is fronted by a -// per-user OAuth connection. IT_TOOLBOX_NAME names that toolbox (the fixture sets it). With the -// startup-deferral fix the container stays routable even though the toolbox cannot enumerate without -// a consented user, and the first user request surfaces an oauth_consent_request. -var consentToolboxName = Environment.GetEnvironmentVariable("IT_TOOLBOX_NAME"); -if (!string.IsNullOrEmpty(consentToolboxName)) +// Scenarios that consume a project toolbox set IT_TOOLBOX_NAME through their fixture. +// The hosting bridge resolves the toolbox through its MCP endpoint and adds its tools to every request. +var toolboxName = Environment.GetEnvironmentVariable("IT_TOOLBOX_NAME"); +if (!string.IsNullOrEmpty(toolboxName)) { - builder.Services.AddFoundryToolboxes(credential, consentToolboxName); + builder.Services.AddFoundryToolboxes(credential, toolboxName); } var app = builder.Build(); @@ -207,6 +208,59 @@ static AIAgent CreateAzureSearchRagAgent(AIProjectClient client, string deployme }); } +static AIAgent CreateWebSearchAnnotationsAgent(AIProjectClient client, string deployment) => + client.AsAIAgent(new ChatClientAgentOptions + { + Name = "web-search-annotations-agent", + Description = "Hosted web search annotation test agent.", + ChatOptions = new ChatOptions + { + ModelId = deployment, + Instructions = """ + Answer with current information from the web search results. + Include citations for the sources used in the answer. + """, + Tools = [new HostedWebSearchTool()], + ToolMode = ChatToolMode.RequireAny, + }, + }); + +static AIAgent CreateAzureSearchToolAnnotationsAgent(AIProjectClient client, string deployment) +{ + var connectionId = Environment.GetEnvironmentVariable("AZURE_SEARCH_CONNECTION_ID") + ?? throw new InvalidOperationException( + "AZURE_SEARCH_CONNECTION_ID is not set for IT_SCENARIO=azure-search-tool-annotations."); + var indexName = Environment.GetEnvironmentVariable("AZURE_SEARCH_INDEX_NAME") + ?? throw new InvalidOperationException( + "AZURE_SEARCH_INDEX_NAME is not set for IT_SCENARIO=azure-search-tool-annotations."); + var searchTool = FoundryAITool.CreateAzureAISearchTool(new AzureAISearchToolOptions( + [ + new AzureAISearchToolIndex + { + ProjectConnectionId = connectionId, + IndexName = indexName, + QueryType = AzureAISearchQueryType.Simple, + TopK = 3, + } + ])); + + return client.AsAIAgent(new ChatClientAgentOptions + { + Name = "azure-search-tool-annotations-agent", + Description = "Azure AI Search hosted tool annotation test agent.", + ChatOptions = new ChatOptions + { + ModelId = deployment, + Instructions = """ + Answer only from the Azure AI Search results. + Include citations for the sources used in the answer. + """, + Tools = [searchTool], + ToolMode = ChatToolMode.RequireAny, + }, + }); +} + static Func>> CreateAzureSearchAdapter(SearchClient client, int top = 3) => async (query, cancellationToken) => diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/AzureSearchToolAnnotationsHostedAgentTests.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/AzureSearchToolAnnotationsHostedAgentTests.cs new file mode 100644 index 00000000000..ada8866dc8a --- /dev/null +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/AzureSearchToolAnnotationsHostedAgentTests.cs @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Foundry.Hosting.IntegrationTests.Fixtures; +using OpenAI.Responses; + +#pragma warning disable OPENAI001 // Experimental Responses API surfaces + +namespace Foundry.Hosting.IntegrationTests; + +/// +/// Verifies that citations produced by the Foundry Azure AI Search hosted tool survive the nested +/// model call and are returned by the hosted Responses API. +/// +[Trait("Category", "FoundryHostedAgents")] +public sealed class AzureSearchToolAnnotationsHostedAgentTests( + AzureSearchToolAnnotationsHostedAgentFixture fixture) + : IClassFixture +{ + private static readonly TimeSpan s_timeout = TimeSpan.FromMinutes(3); + private readonly AzureSearchToolAnnotationsHostedAgentFixture _fixture = fixture; + + [Fact] + public async Task ResponsesApi_Streaming_EmitsUrlCitationAnnotationsAsync() + { + // Arrange + ResponsesClient responses = this._fixture.AgentOpenAIClient.GetProjectResponsesClient(); + CreateResponseOptions options = CreateRequest(); + using CancellationTokenSource timeout = new(s_timeout); + List annotations = []; + StreamingResponseCompletedUpdate? completed = null; + + // Act + await foreach (StreamingResponseUpdate update in responses + .CreateResponseStreamingAsync(options, timeout.Token) + .WithCancellation(timeout.Token)) + { + switch (update) + { + case StreamingResponseOutputTextAnnotationAddedUpdate + { + Annotation: UriCitationMessageAnnotation annotation + }: + annotations.Add(annotation); + break; + + case StreamingResponseCompletedUpdate completedUpdate: + completed = completedUpdate; + break; + + case StreamingResponseFailedUpdate failed: + throw new InvalidOperationException( + $"Hosted Azure AI Search response failed: {failed.Response.Error?.Message}"); + } + } + + // Assert + Assert.NotNull(completed); + Assert.Contains(annotations, IsValidUrlCitation); + Assert.Contains(GetFinalAnnotations(completed.Response), IsValidUrlCitation); + Assert.Contains("TR-CANARY-7821", completed.Response.GetOutputText(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ResponsesApi_NonStreaming_ReturnsUrlCitationAnnotationsAsync() + { + // Arrange + ResponsesClient responses = this._fixture.AgentOpenAIClient.GetProjectResponsesClient(); + CreateResponseOptions options = CreateRequest(); + using CancellationTokenSource timeout = new(s_timeout); + + // Act + ResponseResult response = (await responses.CreateResponseAsync(options, timeout.Token)).Value; + + // Assert + Assert.True( + response.Status == ResponseStatus.Completed, + $"Hosted Azure AI Search response failed: {response.Error?.Message}"); + Assert.Contains("TR-CANARY-7821", response.GetOutputText(), StringComparison.OrdinalIgnoreCase); + Assert.Contains(GetFinalAnnotations(response), IsValidUrlCitation); + } + + private static CreateResponseOptions CreateRequest() + { + CreateResponseOptions options = new() + { + StoredOutputEnabled = false, + }; + options.InputItems.Add(ResponseItem.CreateUserMessageItem( + "What item code do I get with my return? Use Azure AI Search and cite the source.")); + return options; + } + + private static IEnumerable GetFinalAnnotations(ResponseResult response) => + response.OutputItems + .OfType() + .SelectMany(message => message.Content) + .SelectMany(part => part.OutputTextAnnotations) + .OfType(); + + private static bool IsValidUrlCitation(UriCitationMessageAnnotation annotation) => + annotation.Uri.IsAbsoluteUri && + !string.IsNullOrWhiteSpace(annotation.Title) && + annotation.StartIndex >= 0 && + annotation.EndIndex >= annotation.StartIndex; +} diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/AzureSearchToolAnnotationsHostedAgentFixture.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/AzureSearchToolAnnotationsHostedAgentFixture.cs new file mode 100644 index 00000000000..008a5b6dc68 --- /dev/null +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/AzureSearchToolAnnotationsHostedAgentFixture.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using AgentConformance.IntegrationTests.Support; +using Shared.IntegrationTests; + +namespace Foundry.Hosting.IntegrationTests.Fixtures; + +/// +/// Provisions a hosted agent that uses the Foundry Azure AI Search hosted tool and exposes its +/// citations through the hosted Responses API. +/// +public sealed class AzureSearchToolAnnotationsHostedAgentFixture : HostedAgentFixture +{ + private const string DefaultConnectionName = "azure-ai-search-contoso"; + + protected override string ScenarioName => "azure-search-tool-annotations"; + + protected override void ConfigureEnvironment(IDictionary environment) + { + var connectionName = + TestConfiguration.GetValue(TestSettings.AzureSearchConnectionName) ?? + DefaultConnectionName; + environment[TestSettings.AzureSearchConnectionId] = + this.ProjectClient.Connections.GetConnection(connectionName).Value.Id; + environment[TestSettings.AzureSearchIndexName] = + TestConfiguration.GetRequiredValue(TestSettings.AzureSearchIndexName); + } +} diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/WebSearchAnnotationsHostedAgentFixture.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/WebSearchAnnotationsHostedAgentFixture.cs new file mode 100644 index 00000000000..80ed518b035 --- /dev/null +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/WebSearchAnnotationsHostedAgentFixture.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Foundry.Hosting.IntegrationTests.Fixtures; + +/// +/// Provisions a hosted agent that uses +/// and exposes its output through the hosted Responses API. +/// +public sealed class WebSearchAnnotationsHostedAgentFixture : HostedAgentFixture +{ + protected override string ScenarioName => "web-search-annotations"; +} diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md b/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md index 99e0cc43c12..b29c8f85c46 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md @@ -72,7 +72,8 @@ The container scenario injects `USER-ID:` via | `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Foundry project | Model the agent uses. Defaults to `gpt-4o` inside the container. | | `IT_HOSTED_AGENT_IMAGE` | `scripts/it-build-image.ps1` | ACR image reference the agent points at. | | `AZURE_SEARCH_ENDPOINT` | Pre-provisioned Azure AI Search service | Endpoint for the `azure-search-rag` scenario. The index it points at must already exist with the schema and content described under **Azure AI Search index prerequisite** below. | -| `AZURE_SEARCH_INDEX_NAME` | Pre-provisioned Azure AI Search service | Name of the pre-seeded index for the `azure-search-rag` scenario. | +| `AZURE_SEARCH_INDEX_NAME` | Pre-provisioned Azure AI Search service | Name of the pre-seeded index used by both Search scenarios. | +| `AZURE_SEARCH_CONNECTION_NAME` | Foundry project connection | Optional connection name used by the `azure-search-tool-annotations` scenario. Defaults to `azure-ai-search-contoso`. | ## One-time bootstrap (per Foundry project) @@ -97,7 +98,13 @@ running the tests. The bootstrap script grants only `Azure AI User` on the Foundry project scope, which is what every hosted agent needs to receive inbound inference traffic. Scenarios that read from external data services need an additional grant on that service to the agent's managed -identity. Today only the `azure-search-rag` scenario falls into this category. +identity. Both Search scenarios need data-plane access, but they use different identities: + +- `azure-search-rag` calls `SearchClient` inside the container, so the hosted agent's managed + identity needs `Search Index Data Reader`. +- `azure-search-tool-annotations` sends a Foundry Azure AI Search hosted tool through a + `ProjectManagedIdentity` connection, so the Foundry account's managed identity needs the roles + listed below. For `it-azure-search-rag`, after the first bootstrap run, grant `Search Index Data Reader` on the Azure AI Search service to the agent's managed identity: @@ -120,6 +127,35 @@ az role assignment create ` Wait ~3 minutes after the grant for RBAC propagation before running the tests. +The `azure-search-tool-annotations` fixture resolves the pre-provisioned connection by name and +passes its full resource ID plus the index name to the container. The container builds the Azure AI +Search hosted tool descriptor sent with the model request. Creating this descriptor does not +provision a project resource. The connection must use `ProjectManagedIdentity`. Grant the Foundry +account's system-assigned managed identity the roles required by the hosted tool on the Search service: + +```powershell +az role assignment create ` + --assignee-object-id "" ` + --assignee-principal-type ServicePrincipal ` + --role "Search Index Data Contributor" ` + --scope "/subscriptions//resourceGroups//providers/Microsoft.Search/searchServices/" + +az role assignment create ` + --assignee-object-id "" ` + --assignee-principal-type ServicePrincipal ` + --role "Search Service Contributor" ` + --scope "/subscriptions//resourceGroups//providers/Microsoft.Search/searchServices/" +``` + +The integration environment must provision these roles and the connection before running +`AzureSearchToolAnnotationsHostedAgentTests`. + +Projects may also retain a dedicated `ai-search-toolbox` containing the same Azure AI Search tool, +connection, and index for toolbox integration tests. Keep that toolbox separate from the +`AzureSearchToolAnnotationsHostedAgentTests`: toolbox calls surface the search result as tool output, +while these tests specifically verify provider-generated `response.output_text.annotation.added` +events from the hosted tool descriptor. + If the search service has `authOptions = apiKeyOnly` (default for older deployments), Entra auth will return 403 regardless of role assignments. Flip it to `aadOrApiKey` first: @@ -129,8 +165,10 @@ az search service update -g -n --auth-options aadOrApiKey ### Azure AI Search index prerequisite (one time, out of band) -The `azure-search-rag` scenario assumes the index pointed at by `AZURE_SEARCH_INDEX_NAME` already -exists with the schema and Contoso Outdoors content the test asserts against. See +Both Search scenarios assume the index pointed at by `AZURE_SEARCH_INDEX_NAME` already exists +with the schema and Contoso Outdoors content the tests assert against. The index must include +retrievable source name and source URL fields so the hosted tool can return `url_citation` +annotations. See `dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AzureSearchRag/README.md` for the schema and copy-pasteable provisioning snippet. Provisioning the index from your user identity needs `Search Index Data Contributor` on the search service scope. The search service @@ -217,7 +255,7 @@ container, the test fixture, or their tooling changed: | `IT_HOSTED_AGENT_MODEL_DEPLOYMENT_NAME` | `AZURE_AI_MODEL_DEPLOYMENT_NAME` | | `IT_HOSTED_AGENT_REGISTRY` | (consumed by `it-build-image.ps1`; not passed to tests) | | `secrets.AZURE_SEARCH_ENDPOINT` | `AZURE_SEARCH_ENDPOINT` (shared with `python-sample-validation.yml`) | -| `secrets.AZURE_SEARCH_INDEX_NAME` | `AZURE_SEARCH_INDEX_NAME` (shared with `python-sample-validation.yml`) | +| `FOUNDRY_HOSTED_AGENT_SEARCH_INDEX_NAME` | `AZURE_SEARCH_INDEX_NAME` | Like all integration tests in this workflow, the steps run only on `push` and merge-queue events, never on plain `pull_request`. The path-filter list lives in the `paths-filter` @@ -249,6 +287,8 @@ human-only operation; CI only adds and deletes versions under existing agents. | `CustomStorageHostedAgentFixture` | `custom-storage` | `it-custom-storage` | Round trip with custom `IResponsesStorageProvider`; multi turn reads from the custom store (placeholder). | | `MemoryHostedAgentFixture` | `memory` | `it-memory` | `FoundryMemoryProvider` (scoped via `HostedSessionContext`) running inside the hosted agent recalls user preferences across multiple turns; the memory store name is randomised per fixture (`IT_MEMORY_STORE_ID`). | | `AzureSearchRagHostedAgentFixture` | `azure-search-rag` | `it-azure-search-rag` | RAG against a real Azure AI Search index seeded with Contoso Outdoors documents; verifies the model cites the retrieved sources. | +| `AzureSearchToolAnnotationsHostedAgentFixture` | `azure-search-tool-annotations` | `it-azure-search-tool-annotations` | Uses the Azure AI Search hosted tool with a pre-provisioned connection and verifies URL citation annotations through streaming and non-streaming Responses API calls. | +| `WebSearchAnnotationsHostedAgentFixture` | `web-search-annotations` | `it-web-search-annotations` | Calls `HostedWebSearchTool` and verifies URL citation annotations through streaming and non-streaming Responses API calls. | | `SessionFilesHostedAgentFixture` | `session-files` | `it-session-files` | End-to-end: upload via `AgentSessionFiles` (alpha) into a pinned `agent_session_id`, invoke the agent, assert it reads the file via the container's `ReadFile` tool. | | `AgentSkillsHostedAgentFixture` | `agent-skills` | `it-agent-skills` | Agent skills via `AgentSkillsProvider`: advertises two Contoso Outdoors skills (support-style, escalation-policy) in the system prompt, loads them on demand via `load_skill`, verifies canary tokens prove the skill was loaded. | | `ResilientWorkflowHostedAgentFixture` | `resilient-workflow` | `it-resilient-workflow` | Stored background workflow remains active without client traffic, completes after an intentional container process crash, and replays a complete 20-item countdown without a sequence cursor. | diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/WebSearchAnnotationsHostedAgentTests.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/WebSearchAnnotationsHostedAgentTests.cs new file mode 100644 index 00000000000..5bb71c3f92d --- /dev/null +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/WebSearchAnnotationsHostedAgentTests.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Foundry.Hosting.IntegrationTests.Fixtures; +using OpenAI.Responses; + +#pragma warning disable OPENAI001 // Experimental Responses API surfaces + +namespace Foundry.Hosting.IntegrationTests; + +/// +/// Verifies that citations produced by +/// survive the nested model call and are returned by the hosted Responses API. +/// +[Trait("Category", "FoundryHostedAgents")] +public sealed class WebSearchAnnotationsHostedAgentTests( + WebSearchAnnotationsHostedAgentFixture fixture) + : IClassFixture +{ + private static readonly TimeSpan s_timeout = TimeSpan.FromMinutes(3); + private readonly WebSearchAnnotationsHostedAgentFixture _fixture = fixture; + + [Fact] + public async Task ResponsesApi_Streaming_EmitsUrlCitationAnnotationsAsync() + { + // Arrange + ResponsesClient responses = this._fixture.AgentOpenAIClient.GetProjectResponsesClient(); + CreateResponseOptions options = CreateRequest(); + using CancellationTokenSource timeout = new(s_timeout); + List annotations = []; + StreamingResponseCompletedUpdate? completed = null; + + // Act + await foreach (StreamingResponseUpdate update in responses + .CreateResponseStreamingAsync(options, timeout.Token) + .WithCancellation(timeout.Token)) + { + switch (update) + { + case StreamingResponseOutputTextAnnotationAddedUpdate + { + Annotation: UriCitationMessageAnnotation annotation + }: + annotations.Add(annotation); + break; + + case StreamingResponseCompletedUpdate completedUpdate: + completed = completedUpdate; + break; + + case StreamingResponseFailedUpdate failed: + throw new InvalidOperationException( + $"Hosted web search response failed: {failed.Response.Error?.Message}"); + } + } + + // Assert + Assert.NotNull(completed); + Assert.Contains(annotations, IsValidUrlCitation); + Assert.Contains(GetFinalAnnotations(completed.Response), IsValidUrlCitation); + } + + [Fact] + public async Task ResponsesApi_NonStreaming_ReturnsUrlCitationAnnotationsAsync() + { + // Arrange + ResponsesClient responses = this._fixture.AgentOpenAIClient.GetProjectResponsesClient(); + CreateResponseOptions options = CreateRequest(); + using CancellationTokenSource timeout = new(s_timeout); + + // Act + ResponseResult response = (await responses.CreateResponseAsync(options, timeout.Token)).Value; + + // Assert + Assert.Equal(ResponseStatus.Completed, response.Status); + Assert.False(string.IsNullOrWhiteSpace(response.GetOutputText())); + Assert.Contains(GetFinalAnnotations(response), IsValidUrlCitation); + } + + private static CreateResponseOptions CreateRequest() + { + CreateResponseOptions options = new() + { + StoredOutputEnabled = false, + }; + options.InputItems.Add(ResponseItem.CreateUserMessageItem( + "Search the web for the official Microsoft .NET support policy. " + + "Report the current support end date for .NET 10 and cite the official source.")); + return options; + } + + private static IEnumerable GetFinalAnnotations(ResponseResult response) => + response.OutputItems + .OfType() + .SelectMany(message => message.Content) + .SelectMany(part => part.OutputTextAnnotations) + .OfType(); + + private static bool IsValidUrlCitation(UriCitationMessageAnnotation annotation) => + annotation.Uri.IsAbsoluteUri && + !string.IsNullOrWhiteSpace(annotation.Title) && + annotation.StartIndex >= 0 && + annotation.EndIndex >= annotation.StartIndex; +} diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1 b/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1 index 9aa0472bd57..384c4361ea3 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1 +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1 @@ -50,6 +50,8 @@ $Scenarios = @( 'custom-storage', 'memory', 'azure-search-rag', + 'azure-search-tool-annotations', + 'web-search-annotations', 'session-files', 'agent-skills', 'user-identity', diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs index 2b7ae5b0061..509a24a6ab9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs @@ -1366,6 +1366,50 @@ await DrainEventsAsync(handler.CreateAsync( Assert.Equal("set by the container", raw.EndUserId); } + [Fact] + public async Task CreateAsync_ChatClientAnnotationOnlyUpdate_EmitsCitationAsync() + { + // Arrange + var annotation = new CitationAnnotation + { + Url = new Uri("https://example.com/doc"), + Title = "Example Document", + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 0, EndIndex = 5 }] + }; + var client = new Mock(); + client.Setup(c => c.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns(() => ToAsyncEnumerableUpdatesAsync( + new ChatResponseUpdate(ChatRole.Assistant, "Hello") { MessageId = "resp_msg_1" }, + new ChatResponseUpdate( + ChatRole.Assistant, + [new AIContent { Annotations = [annotation] }]) + { + MessageId = "resp_msg_1" + })); + + var agent = new ChatClientAgent(client.Object); + var handler = BuildHandlerWith(agent, new FakeHostedSessionIsolationKeyProvider(), new InMemoryAgentSessionStore()); + + // Act + var events = new List(); + await foreach (var evt in handler.CreateAsync( + NewConversationRequest("conv-citation", "a question", store: true), + NewContextServing("resp_" + new string('c', 46), []), + CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + var annotationEvent = Assert.Single(events.OfType()); + var citation = Assert.IsType(annotationEvent.Annotation); + Assert.Equal(new Uri("https://example.com/doc"), citation.Url); + Assert.Equal("Example Document", citation.Title); + } + private static CreateResponse NewConversationRequest(string conversationId, string text, bool store) { var request = new CreateResponse { Model = "test", Store = store }; diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj index f9e81d5a3ed..41c9906b830 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj @@ -13,6 +13,7 @@ + diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs index d385a763edd..db036cfa73f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs @@ -10,7 +10,16 @@ using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; using Moq; +using ContainerFileCitationMessageAnnotation = OpenAI.Responses.ContainerFileCitationMessageAnnotation; +using FileCitationMessageAnnotation = OpenAI.Responses.FileCitationMessageAnnotation; +using FilePathMessageAnnotation = OpenAI.Responses.FilePathMessageAnnotation; using MeaiTextContent = Microsoft.Extensions.AI.TextContent; +using OpenAIResponseItem = OpenAI.Responses.ResponseItem; +using OpenAIStreamingResponseOutputItemDoneUpdate = OpenAI.Responses.StreamingResponseOutputItemDoneUpdate; +using OpenAIStreamingResponseOutputTextAnnotationAddedUpdate = OpenAI.Responses.StreamingResponseOutputTextAnnotationAddedUpdate; +using OpenAIStreamingResponseOutputTextDeltaUpdate = OpenAI.Responses.StreamingResponseOutputTextDeltaUpdate; + +#pragma warning disable OPENAI001 // Experimental Responses API surfaces namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; @@ -1404,6 +1413,498 @@ public async Task ConvertUpdatesToEventsAsync_TextWithUrlCitationAnnotation_Emit Assert.IsType(events[^1]); } + /// An annotation-only update following text for the same message emits a url_citation event. + [Fact] + public async Task ConvertUpdatesToEventsAsync_AnnotationOnlyContentAfterText_EmitsAnnotationEventAsync() + { + // Arrange + var (stream, _) = CreateTestStream(); + var annotation = new CitationAnnotation + { + Url = new Uri("https://example.com/doc"), + Title = "Example Document", + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 0, EndIndex = 5 }] + }; + var updates = new[] + { + new AgentResponseUpdate + { + MessageId = "msg_1", + Contents = [new MeaiTextContent("Hello")] + }, + new AgentResponseUpdate + { + MessageId = "msg_1", + Contents = [new AIContent { Annotations = [annotation] }] + }, + }; + + // Act + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Assert + var annotationEvent = Assert.Single(events.OfType()); + var urlCitation = Assert.IsType(annotationEvent.Annotation); + Assert.Equal(new Uri("https://example.com/doc"), urlCitation.Url); + Assert.Equal("Example Document", urlCitation.Title); + Assert.Equal(0L, urlCitation.StartIndex); + Assert.Equal(5L, urlCitation.EndIndex); + + var contentPartDone = Assert.Single(events.OfType()); + var donePart = Assert.IsType(contentPartDone.Part); + Assert.IsType(Assert.Single(donePart.Annotations)); + + var outputItemDone = Assert.Single(events.OfType()); + var doneMessage = Assert.IsType(outputItemDone.Item); + var doneText = Assert.IsType(Assert.Single(doneMessage.Content)); + Assert.IsType(Assert.Single(doneText.Annotations)); + } + + /// The same citation attached to text and a later annotation-only update is emitted once. + [Fact] + public async Task ConvertUpdatesToEventsAsync_DuplicateCitationAcrossContentUpdates_EmitsOnceAsync() + { + // Arrange + var (stream, _) = CreateTestStream(); + var annotation = new CitationAnnotation + { + Url = new Uri("https://example.com/doc"), + Title = "Example Document", + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 0, EndIndex = 5 }] + }; + var updates = new[] + { + new AgentResponseUpdate + { + MessageId = "msg_1", + Contents = [new MeaiTextContent("Hello") { Annotations = [annotation] }] + }, + new AgentResponseUpdate + { + MessageId = "msg_1", + Contents = [new AIContent { Annotations = [annotation] }] + }, + }; + + // Act + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Assert + Assert.Single(events.OfType()); + } + + /// An annotation-only update for a different message is not attached to the open message. + [Fact] + public async Task ConvertUpdatesToEventsAsync_AnnotationForDifferentMessage_IsSkippedAsync() + { + // Arrange + var (stream, _) = CreateTestStream(); + var annotation = new CitationAnnotation + { + Url = new Uri("https://example.com/doc"), + Title = "Example Document", + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 0, EndIndex = 5 }] + }; + var updates = new[] + { + new AgentResponseUpdate + { + MessageId = "msg_1", + Contents = [new MeaiTextContent("Hello")] + }, + new AgentResponseUpdate + { + MessageId = "msg_2", + Contents = [new AIContent { Annotations = [annotation] }] + }, + }; + + // Act + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Assert + Assert.Empty(events.OfType()); + } + + /// An annotation-only update without a message ID is not attached to the open message. + [Fact] + public async Task ConvertUpdatesToEventsAsync_AnnotationWithoutMessageId_IsSkippedAsync() + { + // Arrange + var (stream, _) = CreateTestStream(); + var annotation = new CitationAnnotation + { + Url = new Uri("https://example.com/doc"), + Title = "Example Document", + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 0, EndIndex = 5 }] + }; + var updates = new[] + { + new AgentResponseUpdate + { + MessageId = "msg_1", + Contents = [new MeaiTextContent("Hello")] + }, + new AgentResponseUpdate + { + Contents = [new AIContent { Annotations = [annotation] }] + }, + }; + + // Act + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Assert + Assert.Empty(events.OfType()); + } + + /// Generic updates without message IDs can attach annotations to the only open message. + [Fact] + public async Task ConvertUpdatesToEventsAsync_TextAndAnnotationWithoutMessageIds_EmitsAnnotationAsync() + { + // Arrange + var (stream, _) = CreateTestStream(); + var annotation = new CitationAnnotation + { + Url = new Uri("https://example.com/doc"), + Title = "Example Document", + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 0, EndIndex = 5 }] + }; + var updates = new[] + { + new AgentResponseUpdate + { + Contents = [new MeaiTextContent("Hello")] + }, + new AgentResponseUpdate + { + Contents = [new AIContent { Annotations = [annotation] }] + }, + }; + + // Act + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Assert + Assert.Single(events.OfType()); + } + + /// OpenAI item IDs recover correlation when flattened message IDs are absent. + [Fact] + public async Task ConvertUpdatesToEventsAsync_OpenAIRawItemIds_EmitsAnnotationAsync() + { + // Arrange + var (stream, _) = CreateTestStream(); + var annotation = new CitationAnnotation + { + Url = new Uri("https://example.com/doc"), + Title = "Example Document", + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 0, EndIndex = 5 }] + }; + var completedMessage = OpenAIResponseItem.CreateAssistantMessageItem("Hello"); + completedMessage.Id = "msg_raw"; + var updates = new[] + { + new AgentResponseUpdate + { + Contents = [new MeaiTextContent("Hello")], + RawRepresentation = new ChatResponseUpdate + { + RawRepresentation = new OpenAIStreamingResponseOutputTextDeltaUpdate + { + ItemId = "msg_raw" + } + } + }, + new AgentResponseUpdate + { + Contents = [new AIContent { Annotations = [annotation] }], + RawRepresentation = new ChatResponseUpdate + { + RawRepresentation = new OpenAIStreamingResponseOutputItemDoneUpdate + { + Item = completedMessage + } + } + }, + }; + + // Act + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Assert + Assert.Single(events.OfType()); + } + + /// The OpenAI annotation event item ID correlates annotation content when the flattened ID is absent. + [Fact] + public async Task ConvertUpdatesToEventsAsync_OpenAIRawAnnotationItemId_EmitsAnnotationAsync() + { + // Arrange + var (stream, _) = CreateTestStream(); + var annotation = new CitationAnnotation + { + Url = new Uri("https://example.com/doc"), + Title = "Example Document", + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 0, EndIndex = 5 }] + }; + var updates = new[] + { + new AgentResponseUpdate + { + MessageId = "msg_raw", + Contents = [new MeaiTextContent("Hello")] + }, + new AgentResponseUpdate + { + Contents = [new AIContent { Annotations = [annotation] }], + RawRepresentation = new ChatResponseUpdate + { + RawRepresentation = new OpenAIStreamingResponseOutputTextAnnotationAddedUpdate + { + ItemId = "msg_raw" + } + } + }, + }; + + // Act + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Assert + Assert.Single(events.OfType()); + } + + /// An annotation on non-text content is not attached to the open text message. + [Fact] + public async Task ConvertUpdatesToEventsAsync_AnnotationOnDataContent_IsSkippedAsync() + { + // Arrange + var (stream, _) = CreateTestStream(); + var annotation = new CitationAnnotation + { + Url = new Uri("https://example.com/image"), + Title = "Image source", + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 0, EndIndex = 5 }] + }; + var updates = new[] + { + new AgentResponseUpdate + { + MessageId = "msg_1", + Contents = [new MeaiTextContent("Hello")] + }, + new AgentResponseUpdate + { + MessageId = "msg_1", + Contents = + [ + new DataContent("data:image/png;base64,aWNv", "image/png") + { + Annotations = [annotation] + } + ] + }, + }; + + // Act + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Assert + Assert.Empty(events.OfType()); + } + + /// An annotation-only update without an open text message does not create a message. + [Fact] + public async Task ConvertUpdatesToEventsAsync_AnnotationWithoutOpenMessage_IsSkippedAsync() + { + // Arrange + var (stream, _) = CreateTestStream(); + var annotation = new CitationAnnotation + { + Url = new Uri("https://example.com/doc"), + Title = "Example Document", + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 0, EndIndex = 5 }] + }; + var update = new AgentResponseUpdate + { + MessageId = "msg_1", + Contents = [new AIContent { Annotations = [annotation] }] + }; + + // Act + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync([update]), stream)) + { + events.Add(evt); + } + + // Assert + Assert.Single(events); + Assert.IsType(events[0]); + } + + /// An annotation-only file citation is emitted with its file metadata. + [Fact] + public async Task ConvertUpdatesToEventsAsync_AnnotationOnlyFileCitation_EmitsAnnotationEventAsync() + { + // Arrange + var (stream, _) = CreateTestStream(); + var annotation = new CitationAnnotation + { + FileId = "file_123", + Title = "report.pdf", + RawRepresentation = new FileCitationMessageAnnotation("file_123", 2, "report.pdf") + }; + var updates = new[] + { + new AgentResponseUpdate + { + MessageId = "msg_1", + Contents = [new MeaiTextContent("See the report")] + }, + new AgentResponseUpdate + { + MessageId = "msg_1", + Contents = [new AIContent { Annotations = [annotation] }] + }, + }; + + // Act + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Assert + var annotationEvent = Assert.Single(events.OfType()); + var fileCitation = Assert.IsType(annotationEvent.Annotation); + Assert.Equal("file_123", fileCitation.FileId); + Assert.Equal(2L, fileCitation.Index); + Assert.Equal("report.pdf", fileCitation.Filename); + } + + /// An annotation-only file path is emitted with its file metadata. + [Fact] + public async Task ConvertUpdatesToEventsAsync_AnnotationOnlyFilePath_EmitsAnnotationEventAsync() + { + // Arrange + var (stream, _) = CreateTestStream(); + var annotation = new CitationAnnotation + { + FileId = "file_123", + RawRepresentation = new FilePathMessageAnnotation("file_123", 3) + }; + var updates = new[] + { + new AgentResponseUpdate + { + MessageId = "msg_1", + Contents = [new MeaiTextContent("Download the file")] + }, + new AgentResponseUpdate + { + MessageId = "msg_1", + Contents = [new AIContent { Annotations = [annotation] }] + }, + }; + + // Act + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Assert + var annotationEvent = Assert.Single(events.OfType()); + var filePath = Assert.IsType(annotationEvent.Annotation); + Assert.Equal("file_123", filePath.FileId); + Assert.Equal(3L, filePath.Index); + } + + /// An annotation-only container file citation is emitted with its container and span metadata. + [Fact] + public async Task ConvertUpdatesToEventsAsync_AnnotationOnlyContainerFileCitation_EmitsAnnotationEventAsync() + { + // Arrange + var (stream, _) = CreateTestStream(); + var annotation = new CitationAnnotation + { + FileId = "file_123", + Title = "chart.png", + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 4, EndIndex = 9 }], + RawRepresentation = new ContainerFileCitationMessageAnnotation( + "container_123", + "file_123", + 4, + 9, + "chart.png") + }; + var updates = new[] + { + new AgentResponseUpdate + { + MessageId = "msg_1", + Contents = [new MeaiTextContent("See chart")] + }, + new AgentResponseUpdate + { + MessageId = "msg_1", + Contents = [new AIContent { Annotations = [annotation] }] + }, + }; + + // Act + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Assert + var annotationEvent = Assert.Single(events.OfType()); + var containerCitation = Assert.IsType(annotationEvent.Annotation); + Assert.Equal("container_123", containerCitation.ContainerId); + Assert.Equal("file_123", containerCitation.FileId); + Assert.Equal(4L, containerCitation.StartIndex); + Assert.Equal(9L, containerCitation.EndIndex); + Assert.Equal("chart.png", containerCitation.Filename); + } + /// The content_part.done and output_item.done payloads carry the url_citation metadata, guarding against the empty-annotations regression where only the annotation.added event fires. [Fact] public async Task ConvertUpdatesToEventsAsync_TextWithUrlCitationAnnotation_DoneEventsCarryAnnotationMetadataAsync() diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ServiceCollectionExtensionsTests.cs index 855e7b861ff..027572f3cea 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ServiceCollectionExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ServiceCollectionExtensionsTests.cs @@ -2,13 +2,18 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Net; using System.Net.Http; +using System.Net.ServerSentEvents; using System.Text; +using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Azure.AI.AgentServer.Responses; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.AI; @@ -19,6 +24,8 @@ using Moq; using OpenAI.Responses; +#pragma warning disable OPENAI001 // Experimental Responses API surfaces + namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; public class ServiceCollectionExtensionsTests @@ -324,6 +331,215 @@ public async Task MapFoundryResponses_HostedCreateWithoutCallId_ReturnsUnsupport Assert.Contains("2.0.0", body, StringComparison.Ordinal); } + [Fact] + public async Task MapFoundryResponses_StreamTrue_EmitsAllAnnotationKindsAsync() + { + // Arrange and Act + var (statusCode, mediaType, body) = await InvokeResponsesEndpointAsync(stream: true); + + // Assert + Assert.Equal(HttpStatusCode.OK, statusCode); + Assert.Equal("text/event-stream", mediaType); + + var events = await ParseSseEventsAsync(body); + var annotationEvents = events + .Where(e => e.GetProperty("type").GetString() == "response.output_text.annotation.added") + .Select(e => e.GetProperty("annotation")) + .ToArray(); + AssertAnnotations(annotationEvents); + + var contentPartDone = Assert.Single(events, e => e.GetProperty("type").GetString() == "response.content_part.done"); + AssertAnnotations(contentPartDone.GetProperty("part")); + + var outputItemDone = Assert.Single(events, e => e.GetProperty("type").GetString() == "response.output_item.done"); + var outputText = Assert.Single(outputItemDone.GetProperty("item").GetProperty("content").EnumerateArray()); + AssertAnnotations(outputText); + + var completed = Assert.Single(events, e => e.GetProperty("type").GetString() == "response.completed"); + Assert.Equal("response.completed", events[^1].GetProperty("type").GetString()); + var completedOutputItem = Assert.Single(completed.GetProperty("response").GetProperty("output").EnumerateArray()); + var completedOutputText = Assert.Single(completedOutputItem.GetProperty("content").EnumerateArray()); + AssertAnnotations(completedOutputText); + } + + [Fact] + public async Task MapFoundryResponses_StreamFalse_ReturnsAllAnnotationKindsAsync() + { + // Arrange and Act + var (statusCode, mediaType, body) = await InvokeResponsesEndpointAsync(stream: false); + + // Assert + Assert.Equal(HttpStatusCode.OK, statusCode); + Assert.Equal("application/json", mediaType); + + using var document = JsonDocument.Parse(body); + var outputItem = Assert.Single(document.RootElement.GetProperty("output").EnumerateArray()); + var outputText = Assert.Single(outputItem.GetProperty("content").EnumerateArray()); + AssertAnnotations(outputText); + } + + private static async Task<(HttpStatusCode StatusCode, string? MediaType, string Body)> InvokeResponsesEndpointAsync(bool stream) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + AIAgent agent = new ChatClientAgent(CreateAnnotationChatClient()); + builder.Services.AddFoundryResponses(agent, new InMemoryAgentSessionStore()); + builder.Services.AddSingleton(new FakeHostedSessionIsolationKeyProvider()); + builder.Services.AddLogging(); + + await using var app = builder.Build(); + app.MapFoundryResponses(); + await app.StartAsync(); + + var testServer = app.Services.GetRequiredService() as TestServer + ?? throw new InvalidOperationException("TestServer not found"); + using var client = testServer.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, "/responses") + { + Content = new StringContent(CreateRequestJson(stream), Encoding.UTF8, "application/json"), + }; + using var response = await client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + return (response.StatusCode, response.Content.Headers.ContentType?.MediaType, body); + } + + private static IChatClient CreateAnnotationChatClient() + { + var annotations = new AIAnnotation[] + { + new CitationAnnotation + { + Url = new Uri("https://example.com/doc"), + Title = "Example Document", + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 0, EndIndex = 5 }] + }, + new CitationAnnotation + { + FileId = "file_1", + Title = "report.pdf", + RawRepresentation = new FileCitationMessageAnnotation("file_1", 1, "report.pdf") + }, + new CitationAnnotation + { + FileId = "file_2", + RawRepresentation = new FilePathMessageAnnotation("file_2", 2) + }, + new CitationAnnotation + { + FileId = "file_3", + Title = "chart.png", + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 6, EndIndex = 11 }], + RawRepresentation = new ContainerFileCitationMessageAnnotation( + "container_1", + "file_3", + 6, + 11, + "chart.png") + }, + }; + + var client = new Mock(); + client.Setup(c => c.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns(() => ToAsyncEnumerableUpdatesAsync( + new ChatResponseUpdate(ChatRole.Assistant, "Hello sources") { MessageId = "msg_response" }, + new ChatResponseUpdate( + ChatRole.Assistant, + [new AIContent { Annotations = annotations }]) + { + MessageId = "msg_response" + })); + return client.Object; + } + + private static async IAsyncEnumerable ToAsyncEnumerableUpdatesAsync( + params ChatResponseUpdate[] updates) + { + foreach (var update in updates) + { + yield return update; + } + + await Task.CompletedTask; + } + + private static string CreateRequestJson(bool stream) => $$""" + { + "model": "test", + "stream": {{(stream ? "true" : "false")}}, + "input": [ + { + "type": "message", + "id": "msg_request", + "status": "completed", + "role": "user", + "content": [{ "type": "input_text", "text": "Hello" }] + } + ] + } + """; + + private static async Task> ParseSseEventsAsync(string body) + { + var events = new List(); + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(body)); + await foreach (var item in SseParser.Create(stream).EnumerateAsync()) + { + if (item.Data == "[DONE]") + { + continue; + } + + using var document = JsonDocument.Parse(item.Data); + Assert.Equal(document.RootElement.GetProperty("type").GetString(), item.EventType); + events.Add(document.RootElement.Clone()); + } + + return events; + } + + private static void AssertAnnotations(JsonElement outputText) => + AssertAnnotations(outputText.GetProperty("annotations").EnumerateArray().ToArray()); + + private static void AssertAnnotations(IReadOnlyCollection annotations) + { + Assert.Collection( + annotations, + annotation => + { + Assert.Equal("url_citation", annotation.GetProperty("type").GetString()); + Assert.Equal("https://example.com/doc", annotation.GetProperty("url").GetString()); + Assert.Equal("Example Document", annotation.GetProperty("title").GetString()); + Assert.Equal(0, annotation.GetProperty("start_index").GetInt64()); + Assert.Equal(5, annotation.GetProperty("end_index").GetInt64()); + }, + annotation => + { + Assert.Equal("file_citation", annotation.GetProperty("type").GetString()); + Assert.Equal("file_1", annotation.GetProperty("file_id").GetString()); + Assert.Equal(1, annotation.GetProperty("index").GetInt64()); + Assert.Equal("report.pdf", annotation.GetProperty("filename").GetString()); + }, + annotation => + { + Assert.Equal("file_path", annotation.GetProperty("type").GetString()); + Assert.Equal("file_2", annotation.GetProperty("file_id").GetString()); + Assert.Equal(2, annotation.GetProperty("index").GetInt64()); + }, + annotation => + { + Assert.Equal("container_file_citation", annotation.GetProperty("type").GetString()); + Assert.Equal("container_1", annotation.GetProperty("container_id").GetString()); + Assert.Equal("file_3", annotation.GetProperty("file_id").GetString()); + Assert.Equal(6, annotation.GetProperty("start_index").GetInt64()); + Assert.Equal(11, annotation.GetProperty("end_index").GetInt64()); + Assert.Equal("chart.png", annotation.GetProperty("filename").GetString()); + }); + } + private static async Task BuildTestHostAsync( Action configure, Action? configureBuilder = null) From 5e9db9fb65e9b596a4576dcc6bb72f991fb7ba41 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 00:01:58 +0000 Subject: [PATCH 40/85] Chore(deps): Bump actions/setup-node (#8102) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.4.0 to 7.0.0. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e...820762786026740c76f36085b0efc47a31fe5020) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/sample-validation-setup/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/sample-validation-setup/action.yml b/.github/actions/sample-validation-setup/action.yml index 531ae6b6504..5887f16ee25 100644 --- a/.github/actions/sample-validation-setup/action.yml +++ b/.github/actions/sample-validation-setup/action.yml @@ -24,7 +24,7 @@ runs: using: "composite" steps: - name: Set up Node.js environment - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 From 843a7b5e54f50eac7b88c211eafcec0ac5d61804 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 00:03:04 +0000 Subject: [PATCH 41/85] Chore(deps): Bump actions/setup-node from 4.4.0 to 7.0.0 (#8101) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4.4.0 to 7.0.0. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v4.4.0...820762786026740c76f36085b0efc47a31fe5020) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/github-automation-tests.yml | 2 +- .github/workflows/markdown-link-check.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/github-automation-tests.yml b/.github/workflows/github-automation-tests.yml index a1597f7b0e6..e03b1da575f 100644 --- a/.github/workflows/github-automation-tests.yml +++ b/.github/workflows/github-automation-tests.yml @@ -27,7 +27,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" diff --git a/.github/workflows/markdown-link-check.yml b/.github/workflows/markdown-link-check.yml index 6d1635837cb..9a43b516ef0 100644 --- a/.github/workflows/markdown-link-check.yml +++ b/.github/workflows/markdown-link-check.yml @@ -24,7 +24,7 @@ jobs: persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 20 From 25a3dcbbe655a6ed1ceb18f55cf08dc69a03db2d Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Mon, 7 Sep 2026 00:07:47 +0000 Subject: [PATCH 42/85] Python: fix(a2a): initialize _close_http_client on the user-supplied-client path (#8095) A2AAgent(url=..., http_client=mine) never set _close_http_client, so exiting the async context manager raised AttributeError instead of leaving the caller's client alone. Default the flag to False up front; the two paths that create the client still flip it to True. Fixes #7950 Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com> --- .../a2a/agent_framework_a2a/_agent.py | 2 ++ python/packages/a2a/tests/test_a2a_agent.py | 34 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/python/packages/a2a/agent_framework_a2a/_agent.py b/python/packages/a2a/agent_framework_a2a/_agent.py index 3d5ac41e86b..1261305bfbc 100644 --- a/python/packages/a2a/agent_framework_a2a/_agent.py +++ b/python/packages/a2a/agent_framework_a2a/_agent.py @@ -263,6 +263,8 @@ def __init__( super().__init__(id=id, name=name, description=description, **kwargs) self._http_client: httpx.AsyncClient | None = http_client + # every construction path must set this before __aexit__ can run + self._close_http_client = False self._timeout_config = self._create_timeout_config(timeout) bindings = supported_protocol_bindings if supported_protocol_bindings is not None else ["JSONRPC"] if client is not None: diff --git a/python/packages/a2a/tests/test_a2a_agent.py b/python/packages/a2a/tests/test_a2a_agent.py index 8ff147f98aa..9c111a2b11e 100644 --- a/python/packages/a2a/tests/test_a2a_agent.py +++ b/python/packages/a2a/tests/test_a2a_agent.py @@ -2536,3 +2536,37 @@ async def test_input_required_sets_task_id_instead_of_reference(mock_a2a_client: # endregion + + +async def test_context_manager_with_user_supplied_http_client() -> None: + """A2AAgent(url=..., http_client=mine) must exit cleanly and must not close it. + + The third construction path (no client, caller-provided http_client) used to + leave _close_http_client unset, so __aexit__ raised AttributeError. + """ + mock_http_client = MagicMock() + mock_http_client.aclose = AsyncMock() + + agent = A2AAgent(url="http://agent.example", http_client=mock_http_client) + + async with agent: + pass + + # the client belongs to the caller; we must not close it + mock_http_client.aclose.assert_not_called() + + +async def test_context_manager_closes_self_created_http_client() -> None: + """A2AAgent(url=...) builds its own client and closes it on exit.""" + + with patch("agent_framework_a2a._agent.httpx.AsyncClient") as cls: + mock_http_client = MagicMock() + mock_http_client.aclose = AsyncMock() + cls.return_value = mock_http_client + + agent = A2AAgent(url="http://agent.example") + + async with agent: + pass + + mock_http_client.aclose.assert_called_once() From 3904a2fc213bda9172aa1be2d7dbb6ab4ffcaab1 Mon Sep 17 00:00:00 2001 From: Shxiao Date: Mon, 7 Sep 2026 00:13:50 +0000 Subject: [PATCH 43/85] Python: Docs: Fix broken external links in Python samples and docs (#8090) * Docs: fix broken external links in Python samples and docs * Docs: align Toolbox overview description with target page content --- .../0033-feature-usage-bitmask-user-agent.md | 2 +- python/packages/chatkit/README.md | 2 +- .../responses/foundry_toolbox/README.md | 20 +++++++++---------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/decisions/0033-feature-usage-bitmask-user-agent.md b/docs/decisions/0033-feature-usage-bitmask-user-agent.md index 059f521c69a..9a0b0b14e8b 100644 --- a/docs/decisions/0033-feature-usage-bitmask-user-agent.md +++ b/docs/decisions/0033-feature-usage-bitmask-user-agent.md @@ -532,7 +532,7 @@ Sources: botocore [`useragent.py`](https://github.com/boto/botocore/blob/develop openai-python [`_base_client.py` `platform_headers()`](https://github.com/openai/openai-python/blob/main/src/openai/_base_client.py); anthropic-sdk-python [`_base_client.py`](https://github.com/anthropics/anthropic-sdk-python/blob/main/src/anthropic/_base_client.py); azure-core [`_universal.py` `UserAgentPolicy`](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/core/azure-core/azure/core/pipeline/policies/_universal.py); -google-api-core [`client_info.py`](https://github.com/googleapis/python-api-core/blob/main/google/api_core/client_info.py); +google-api-core [`client_info.py`](https://github.com/googleapis/google-cloud-python/blob/main/packages/google-api-core/google/api_core/client_info.py); langsmith-sdk [`client.py`](https://github.com/langchain-ai/langsmith-sdk/blob/main/python/langsmith/client.py) / [`utils.py`](https://github.com/langchain-ai/langsmith-sdk/blob/main/python/langsmith/utils.py); huggingface_hub [`constants.py`](https://github.com/huggingface/huggingface_hub/blob/main/src/huggingface_hub/constants.py). diff --git a/python/packages/chatkit/README.md b/python/packages/chatkit/README.md index 7caacd4d823..c4cdb364316 100644 --- a/python/packages/chatkit/README.md +++ b/python/packages/chatkit/README.md @@ -2,7 +2,7 @@ This package provides an integration layer between Microsoft Agent Framework and [OpenAI ChatKit (Python)](https://github.com/openai/chatkit-python/). -Specifically, it mirrors the [Agent SDK integration](https://github.com/openai/chatkit-python/blob/main/docs/server.md#agents-sdk-integration), and provides the following helpers: +Specifically, it mirrors the [Agent SDK integration](https://github.com/openai/chatkit-python/blob/main/docs/quickstart.md#generate-model-responses), and provides the following helpers: - `stream_agent_response`: A helper to convert a streamed `AgentResponseUpdate` from a Microsoft Agent Framework agent that implements `SupportsAgentRun` to ChatKit events. diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_toolbox/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_toolbox/README.md index c39f180f404..0eab3777fb2 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_toolbox/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_toolbox/README.md @@ -4,7 +4,7 @@ An [Agent Framework](https://github.com/microsoft/agent-framework) agent that us ## Creating a Foundry Toolbox -You can create a Foundry Toolbox by code. Refer to this sample for an example: [Foundry Toolbox CRUD Sample](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_toolboxes_crud.py). +You can create a Foundry Toolbox by code. Refer to this sample for an example: [Foundry Toolbox CRUD Sample](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/samples/toolboxes/sample_toolboxes_crud.py). You can also create a Foundry Toolbox in the Foundry portal. Read more about it [in the Foundry toolbox documentation](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/toolbox). @@ -21,13 +21,13 @@ This sample consumes a toolbox over its MCP endpoint. It bundles a [`toolbox.yam You can connect to MCP servers in Foundry Toolbox that use different authentication methods. This sample demonstrates the following authentication methods: -- [**No authentication**](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS.md#5-mcp-no-auth): The tool does not require any authentication. The agent can invoke the tool without providing any credentials. Sample MCP server: `https://gitmcp.io/Azure/azure-rest-api-specs` -- [**Key-based authentication**](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS.md#4-mcp-key-auth-github): The tool requires a key to authenticate. Sample MCP server: `https://api.githubcopilot.com/mcp` (GitHub MCP server) with a Personal Access Token (PAT) for authentication. -- [**OAuth2 authentication (managed)**](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS.md#6-mcp-oauth-managed-connector): The tool requires OAuth2 to authenticate. Sample MCP server: `https://api.githubcopilot.com/mcp` (GitHub MCP server) with OAuth2 for authentication. -- [**Agent identity authentication**](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS.md#8-mcp-agent-identity): The tool requires an agent identity token to authenticate. Sample MCP server: `https://{foundry-resource-name}.cognitiveservices.azure.com/language/mcp?api-version=2025-11-15-preview` ([Azure Language MCP server](https://learn.microsoft.com/en-us/azure/ai-services/language-service/concepts/foundry-tools-agents#azure-language-mcp-server-preview)) with agent identity for authentication. -- [**Entra Pass-through authentication**](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS.md#13-mcp-oauth-entra-passthrough): The tool requires an Entra pass-through token to authenticate; Foundry forwards the calling user's Entra token to the MCP server. Sample MCP server: the [Microsoft Foundry MCP server](https://learn.microsoft.com/en-us/azure/foundry/mcp/get-started?view=foundry&tabs=user), which exposes Foundry model-catalog, evaluation, agent, and session tools and requires only that the caller have access to the Foundry project (no extra license). +- [**No authentication**](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS/tools/mcp-unauthenticated.md): The tool does not require any authentication. The agent can invoke the tool without providing any credentials. Sample MCP server: `https://gitmcp.io/Azure/azure-rest-api-specs` +- [**Key-based authentication**](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS/tools/mcp-key-auth.md): The tool requires a key to authenticate. Sample MCP server: `https://api.githubcopilot.com/mcp` (GitHub MCP server) with a Personal Access Token (PAT) for authentication. +- [**OAuth2 authentication (managed)**](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS/tools/mcp-oauth-managed.md): The tool requires OAuth2 to authenticate. Sample MCP server: `https://api.githubcopilot.com/mcp` (GitHub MCP server) with OAuth2 for authentication. +- [**Agent identity authentication**](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS/tools/mcp-microsoft-entra.md): The tool requires an agent identity token to authenticate. Sample MCP server: `https://{foundry-resource-name}.cognitiveservices.azure.com/language/mcp?api-version=2025-11-15-preview` ([Azure Language MCP server](https://learn.microsoft.com/en-us/azure/ai-services/language-service/concepts/foundry-tools-agents#azure-language-mcp-server-preview)) with agent identity for authentication. +- [**Entra Pass-through authentication**](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS/tools/mcp-user-entra-token.md): The tool requires an Entra pass-through token to authenticate; Foundry forwards the calling user's Entra token to the MCP server. Sample MCP server: the [Microsoft Foundry MCP server](https://learn.microsoft.com/en-us/azure/foundry/mcp/get-started?view=foundry&tabs=user), which exposes Foundry model-catalog, evaluation, agent, and session tools and requires only that the caller have access to the Foundry project (no extra license). -There are also Non-MCP tools in the toolbox that support different authentication methods. Learn more at the [Foundry sample repository](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS.md). +There are also Non-MCP tools in the toolbox that support different authentication methods. Learn more at the [Foundry sample repository](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS/README.md). ### Finding the Entra audience for an MCP server @@ -131,8 +131,8 @@ Follow the prompts to configure your Foundry project and model deployment. If yo > [!TIP] > If you use GitHub Copilot for Azure to scaffold a hosted agent that consumes this toolbox, the following skill references describe the same endpoint contract (env var, headers, MCP protocol, citation patterns, and troubleshooting) that the agent must implement: > -> - [Toolbox reference](https://github.com/microsoft/GitHub-Copilot-for-Azure/blob/main/plugin/skills/microsoft-foundry/foundry-agent/create/references/toolbox-reference.md) — endpoint format, MCP protocol, OAuth consent handling, citation patterns, and troubleshooting. -> - [Use toolbox in a hosted agent](https://github.com/microsoft/GitHub-Copilot-for-Azure/blob/main/plugin/skills/microsoft-foundry/foundry-agent/create/references/use-toolbox-in-hosted-agent.md) — endpoint resolution, env-var contract, payload shape, code integration patterns, and tracing. +> - [Toolbox overview](https://github.com/microsoft/GitHub-Copilot-for-Azure/blob/main/plugins/azure-skills/skills/microsoft-foundry/foundry-agent/toolbox/toolbox.md) — toolbox concept, API shape and schema, versions, endpoints, and MCP protocol. +> - [Use toolbox in a hosted agent](https://github.com/microsoft/GitHub-Copilot-for-Azure/blob/main/plugins/azure-skills/skills/microsoft-foundry/foundry-agent/create/references/use-toolbox-in-hosted-agent.md) — endpoint resolution, env-var contract, payload shape, code integration patterns, and tracing. The agent reads the toolbox's MCP endpoint from `TOOLBOX_ENDPOINT`. Create the toolbox once from the bundled [`toolbox.yaml`](toolbox.yaml): @@ -223,7 +223,7 @@ Press **F5** to start the agent in debug mode. The agent host will start on `htt ### Creating a Foundry Toolbox -You can create a Foundry Toolbox by code. Refer to this sample for an example: [Foundry Toolbox CRUD Sample](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/samples/hosted_agents/sample_toolboxes_crud.py). +You can create a Foundry Toolbox by code. Refer to this sample for an example: [Foundry Toolbox CRUD Sample](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/ai/azure-ai-projects/samples/toolboxes/sample_toolboxes_crud.py). You can also create a Foundry Toolbox in the Foundry portal. Read more about it [in the Foundry toolbox documentation](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/toolbox). From afdc0db172d57edd50976d6f15168898f2284916 Mon Sep 17 00:00:00 2001 From: George Pickett Date: Mon, 7 Sep 2026 00:32:26 +0000 Subject: [PATCH 44/85] Python: Document anonymous Parallel Search MCP usage (#8084) * Python: Document anonymous Parallel Search MCP usage * Python: Avoid duplicate MCP sample output and guard entry point --- python/samples/02-agents/mcp/README.md | 54 ++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/python/samples/02-agents/mcp/README.md b/python/samples/02-agents/mcp/README.md index e3fce52794b..83013a0e896 100644 --- a/python/samples/02-agents/mcp/README.md +++ b/python/samples/02-agents/mcp/README.md @@ -17,6 +17,60 @@ The Model Context Protocol (MCP) is an open standard for connecting AI agents to | **Progressive Disclosure** | [`mcp_progressive_disclosure.py`](mcp_progressive_disclosure.py) | Demonstrates `use_progressive_disclosure`, `always_load`, `allowed_tools`, and prefixed `list_mcp_tools` / `load_tool` / `unload_tool` names. `load_tool` and `unload_tool` can accept one tool name or multiple names. Self-spawns a stdio MCP child server | | **Sampling Approval** | [`mcp_sampling_approval.py`](mcp_sampling_approval.py) | Demonstrates gating server-initiated `sampling/createMessage` requests with a `sampling_approval_callback`, plus the `sampling_max_tokens` and `sampling_max_requests` guardrails. MCP sampling is denied by default | +## Anonymous web search and fetch + +Use `MCPStreamableHTTPTool` with [Parallel Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp) to search the public web and extract page content. This example calls the tools directly, so it needs neither a Parallel API key nor a model provider account. Free access is rate limited. + +Install the client dependencies in a Python 3.10+ environment: + +```bash +pip install agent-framework-core "mcp>=1.24,<2" +``` + +Save this as `parallel_search.py` and run `python parallel_search.py`: + +```python +import asyncio +from uuid import uuid4 + +from agent_framework import MCPStreamableHTTPTool + + +async def main() -> None: + session_id = str(uuid4()) # Reuse for related search and fetch calls. + async with MCPStreamableHTTPTool( + name="parallel-search", + url="https://search.parallel.ai/mcp", + load_prompts=False, + request_timeout=30, + # Use the text payload once; Parallel also returns it as structured content. + parse_tool_results=lambda result: "\n".join(c.text for c in result.content if c.type == "text"), + ) as mcp: + print("Tools:", [tool.name for tool in mcp.functions]) + search_result = await mcp.call_tool( + "web_search", + objective="Find Microsoft Agent Framework MCP documentation", + search_queries=["Microsoft Agent Framework MCP tools"], + session_id=session_id, + ) + fetch_result = await mcp.call_tool( + "web_fetch", + urls=["https://github.com/microsoft/agent-framework"], + objective="Describe the framework's MCP support", + session_id=session_id, + ) + for result in (search_result, fetch_result): + print(result) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +The output lists `web_search` and `web_fetch`, followed by their results, including source URLs and excerpts. Queries, requested URLs, objectives, and the session identifier are sent to Parallel when the calls run. The context manager closes the connection afterward. + +To make these tools available to an existing agent, pass the MCP tool as `tools` when constructing `Agent`. The agent can then choose to invoke them during a run; remove that tool to disable access. This example does not change any configured providers or defaults. + ## Prerequisites Most samples in this folder use OpenAI: From 28382b255e6895d0f29f411d3dae8d27f832e3d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=87=E5=AE=99=E7=9A=84=E5=B1=B1=E5=B3=B0?= Date: Mon, 7 Sep 2026 06:09:24 +0000 Subject: [PATCH 45/85] Python: skip optional lightning test on import errors (#8071) Co-authored-by: UniversePeak --- python/packages/lab/lightning/tests/test_lightning.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/python/packages/lab/lightning/tests/test_lightning.py b/python/packages/lab/lightning/tests/test_lightning.py index 8f1602b5660..85a829ffe06 100644 --- a/python/packages/lab/lightning/tests/test_lightning.py +++ b/python/packages/lab/lightning/tests/test_lightning.py @@ -126,7 +126,9 @@ async def test_observability(workflow_two_agents: Workflow): | | [chat gpt-4o] [chat gpt-4o] """ - pytest.importorskip("agentlightning") + # agentlightning imports optional proxy dependencies that may fail independently. + # Keep this optional observability test skippable when any such import is broken. + pytest.importorskip("agentlightning", exc_type=ImportError) from agent_framework_lab_lightning import AgentFrameworkTracer from agentlightning.adapter import TracerTraceToTriplet From 26832bed1fa192febbb0ceec16d5a2900a39eb27 Mon Sep 17 00:00:00 2001 From: vedanth bora <75097551+VedanthB@users.noreply.github.com> Date: Mon, 7 Sep 2026 06:09:27 +0000 Subject: [PATCH 46/85] fix(chatkit): preserve input tags in user messages (#8097) --- .../agent_framework_chatkit/_converter.py | 35 ++++--- .../packages/chatkit/tests/test_converter.py | 96 ++++++++++++++++++- 2 files changed, 115 insertions(+), 16 deletions(-) diff --git a/python/packages/chatkit/agent_framework_chatkit/_converter.py b/python/packages/chatkit/agent_framework_chatkit/_converter.py index c4fe24670f2..88f75598b5b 100644 --- a/python/packages/chatkit/agent_framework_chatkit/_converter.py +++ b/python/packages/chatkit/agent_framework_chatkit/_converter.py @@ -81,33 +81,40 @@ async def user_message_to_input( """ # Extract text content from the user message text_content = "" + contents: list[Content] = [] + + def append_text_content() -> None: + nonlocal text_content + if stripped_text := text_content.strip(): + contents.append(Content.from_text(text=stripped_text)) + text_content = "" + if item.content: for content_part in item.content: if isinstance(content_part, UserMessageTextContent): text_content += content_part.text + elif isinstance(content_part, UserMessageTagContent): + tag_content = self.tag_to_message_content(content_part) + if tag_content.type == "text": + text_content += tag_content.text or "" + else: + append_text_content() + contents.append(tag_content) + + append_text_content() - # Convert attachments to Content - data_contents: list[Content] = [] + # Append attachments after the ordered message content. if item.attachments: for attachment in item.attachments: content = await self.attachment_to_message_content(attachment) if content is not None: - data_contents.append(content) + contents.append(content) # Create the message with text and attachments - if not text_content.strip() and not data_contents: + if not contents: return None - # If only text and no attachments, use text parameter for simplicity - if text_content.strip() and not data_contents: - user_message = Message(role="user", contents=[text_content.strip()]) - else: - # Build contents list with both text and attachments - contents: list[Content] = [] - if text_content.strip(): - contents.append(Content.from_text(text=text_content.strip())) - contents.extend(data_contents) - user_message = Message(role="user", contents=contents) + user_message = Message(role="user", contents=contents) # Handle quoted text if this is the last message messages = [user_message] diff --git a/python/packages/chatkit/tests/test_converter.py b/python/packages/chatkit/tests/test_converter.py index 730fd6ec6e1..a0fb0244549 100644 --- a/python/packages/chatkit/tests/test_converter.py +++ b/python/packages/chatkit/tests/test_converter.py @@ -7,8 +7,8 @@ from unittest.mock import Mock import pytest -from agent_framework import Message -from chatkit.types import InferenceOptions, UserMessageTextContent +from agent_framework import Content, Message +from chatkit.types import InferenceOptions, UserMessageTagContent, UserMessageTextContent from pydantic import AnyUrl from agent_framework_chatkit import ThreadItemConverter, simple_to_agent_input @@ -110,6 +110,98 @@ async def test_to_agent_input_multiple_content_parts(self, converter): assert len(result) == 1 assert result[0].text == "Hello world!" + async def test_to_agent_input_keeps_tag_inline_with_text(self, converter): + """Test converting user message tags in their original text position.""" + from chatkit.types import UserMessageItem + + input_item = UserMessageItem( + id="msg_tag", + thread_id="thread_1", + created_at=datetime.now(), + type="user_message", + content=[ + UserMessageTextContent(text="Ask "), + UserMessageTagContent( + type="input_tag", + id="tag_1", + text="john", + data={"name": "John Doe"}, + interactive=False, + ), + UserMessageTextContent(text=" about the report."), + ], + attachments=[], + inference_options=InferenceOptions(), + ) + + result = await converter.to_agent_input(input_item) + + assert len(result) == 1 + assert result[0].text == "Ask Name:John Doe about the report." + + async def test_to_agent_input_keeps_tag_only_message(self, converter): + """Test that a user message containing only a tag is not discarded.""" + from chatkit.types import UserMessageItem + + input_item = UserMessageItem( + id="msg_tag_only", + thread_id="thread_1", + created_at=datetime.now(), + type="user_message", + content=[ + UserMessageTagContent( + type="input_tag", + id="tag_1", + text="john", + data={"name": "John Doe"}, + interactive=False, + ) + ], + attachments=[], + inference_options=InferenceOptions(), + ) + + result = await converter.to_agent_input(input_item) + + assert len(result) == 1 + assert result[0].text == "Name:John Doe" + + async def test_to_agent_input_preserves_non_text_tag_position(self): + """Test that custom non-text tag conversions remain between adjacent text.""" + from chatkit.types import UserMessageItem + + class UriTagConverter(ThreadItemConverter): + def tag_to_message_content(self, tag: UserMessageTagContent) -> Content: + return Content.from_uri(uri=f"https://example.com/users/{tag.text}", media_type="text/html") + + input_item = UserMessageItem( + id="msg_uri_tag", + thread_id="thread_1", + created_at=datetime.now(), + type="user_message", + content=[ + UserMessageTextContent(text="Ask"), + UserMessageTagContent( + type="input_tag", + id="tag_1", + text="john", + data={"name": "John Doe"}, + interactive=False, + ), + UserMessageTextContent(text="about the report."), + ], + attachments=[], + inference_options=InferenceOptions(), + ) + + result = await UriTagConverter().to_agent_input(input_item) + + assert len(result) == 1 + assert [content.type for content in result[0].contents] == ["text", "uri", "text"] + assert result[0].contents[0].text == "Ask" + assert result[0].contents[1].uri == "https://example.com/users/john" + assert result[0].contents[2].text == "about the report." + async def test_to_agent_input_with_quoted_text_for_last_message(self, converter): """Test quoted text is prepended as context for the last user message.""" from chatkit.types import UserMessageItem From d4c7312bf39b9b5d976515350a3c39827c2ef8fd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 07:36:17 +0000 Subject: [PATCH 47/85] Python: Chore(deps-dev): Bump ty from 0.0.72 to 0.0.75 in /python (#8062) * Chore(deps-dev): Bump ty from 0.0.72 to 0.0.75 in /python Bumps [ty](https://github.com/astral-sh/ty) from 0.0.72 to 0.0.75. - [Release notes](https://github.com/astral-sh/ty/releases) - [Changelog](https://github.com/astral-sh/ty/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ty/compare/0.0.72...0.0.75) --- updated-dependencies: - dependency-name: ty dependency-version: 0.0.75 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * Python: Update lockfile for ty 0.0.75 Co-authored-by: moonbox3 <35585003+moonbox3@users.noreply.github.com> --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: moonbox3 <35585003+moonbox3@users.noreply.github.com> --- python/pyproject.toml | 2 +- python/uv.lock | 58 ++++++++++++++++--------------------------- 2 files changed, 23 insertions(+), 37 deletions(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index 7856ff7043a..f9b168f6470 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -40,7 +40,7 @@ dev = [ "mypy==2.3.1", "pyright==1.1.411", "pyrefly==1.2.0", - "ty==0.0.72", + "ty==0.0.75", "zuban==0.9.2", "opentelemetry-sdk", #tasks diff --git a/python/uv.lock b/python/uv.lock index b331b021348..529311c8725 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -179,7 +179,7 @@ dev = [ { name = "rich", specifier = ">=13.7.1,<16.0.0" }, { name = "ruff", specifier = "==0.16.4" }, { name = "tomli", specifier = "==2.4.1" }, - { name = "ty", specifier = "==0.0.72" }, + { name = "ty", specifier = "==0.0.75" }, { name = "uv", specifier = "==0.12.6" }, { name = "zuban", specifier = "==0.9.2" }, ] @@ -3031,9 +3031,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/16/71eefcf68267bbf06a9b6bff57d0b222e49432326e85d74348b67694b8d4/greenlet-3.5.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e883de250e299654b1f1680f72a1a9f9ba62c9bd1bce84099c90657349a8dfbb", size = 294266, upload-time = "2026-07-22T11:37:56.142Z" }, { url = "https://files.pythonhosted.org/packages/36/ea/a0b19adfc35d07e10acb626e9d22a3893b95f1309c42c4a20161dec16800/greenlet-3.5.4-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32802705c2c1ff25e8237b3bdacf2594fa02be80af8a66703eb7853ea7e68686", size = 613712, upload-time = "2026-07-22T12:26:39.375Z" }, { url = "https://files.pythonhosted.org/packages/54/76/a121978b3337407d05a1ce5f79b4aa5998a43a9d8422f9726029b90b4471/greenlet-3.5.4-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57aa201b351f7c7c75627c60d29e4d5b97a07d37efeb62b903466fca42c097d7", size = 625582, upload-time = "2026-07-22T12:29:00.814Z" }, - { url = "https://files.pythonhosted.org/packages/d1/4a/f301f1d85c69a86b90b5d581a73e8927bba4e79450037e6e2cbca05eb4fd/greenlet-3.5.4-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9667862a2e38ad379f11b845daeda22c8989186def44f06962c9c4c05e556da7", size = 633429, upload-time = "2026-07-22T12:43:42.073Z" }, { url = "https://files.pythonhosted.org/packages/34/c2/080f16cf870e929e592f55767f01d6c98d2ee83bfdc36c3b892f2d0459ab/greenlet-3.5.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3fe76c2cac86b4f7a1e92865ac0a54384deb05c92986287c1a7110d9bd53071", size = 624663, upload-time = "2026-07-22T11:51:08.016Z" }, - { url = "https://files.pythonhosted.org/packages/6f/2e/26884072b0eb343a4d5fee903341bfe5171b32b7f14553886e2b6349135a/greenlet-3.5.4-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:ae53534b5dec0f4c2ec26f898f538dc8ea1ca3ef2927d597a9439e40a09da937", size = 428238, upload-time = "2026-07-22T12:39:49.973Z" }, { url = "https://files.pythonhosted.org/packages/9e/bb/8f3ca88370b817369008faeceeee85970adc16c92a70a3e5fe5fea495a57/greenlet-3.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1e1a4a684b16c45ba324e60b32a4386a87722bcb815d2a149d2182f9b401ca72", size = 1585010, upload-time = "2026-07-22T12:25:02.539Z" }, { url = "https://files.pythonhosted.org/packages/51/c2/45877154689709ebce9a0b83c2235e6ca0f31577889b02af308c8cc5f8fb/greenlet-3.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e849e6e139b9671adeac505f72fc05f4af7fd1921faef40295e214fc3b361b59", size = 1651283, upload-time = "2026-07-22T11:51:10.408Z" }, { url = "https://files.pythonhosted.org/packages/cd/7d/8711a75cb61d85246277c07ff6e1a6504621ba473d808c11ad225ffca43f/greenlet-3.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:dc418cf4c873357964d6624445ed09472e50def990c65dd4e76fc3ba8cd9cef6", size = 246434, upload-time = "2026-07-22T11:43:15.557Z" }, @@ -3041,9 +3039,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/04/81bd731d6d1e3a469d9a4c36f5eb069bcf0cbb2d5d342c9fec22245b91fc/greenlet-3.5.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3d66250e8b09f182ede05490998c818b5961f7a3640332d44c4927caec7bbfe4", size = 295909, upload-time = "2026-07-22T11:38:09.261Z" }, { url = "https://files.pythonhosted.org/packages/cc/dd/f5f22903a6ae70f5ea328ed0beaec92ad903f0e3b7d2845133b354abc4b8/greenlet-3.5.4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90e930c9c192e5b3ee9fb8bcd920ea3926155e2e3ded39fc697323addecee17", size = 612011, upload-time = "2026-07-22T12:26:40.69Z" }, { url = "https://files.pythonhosted.org/packages/8e/10/92a4a88d12b915d74ea5b6d288e4afefda4771647caa34442c156f7a454f/greenlet-3.5.4-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:791fdfeeb9c6e0c7b10fa151bf110d2a6974866f13dcb5b1c7efae698245893a", size = 624299, upload-time = "2026-07-22T12:29:02.089Z" }, - { url = "https://files.pythonhosted.org/packages/6c/f9/03e26be3487c5238e81f2b84714959a86ea8515a869828cf41f4fc54b34e/greenlet-3.5.4-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b7c895310363f310361e0fe2072af85269d2a2a285cd04c0c59e79a5e3670dcf", size = 629603, upload-time = "2026-07-22T12:43:43.456Z" }, { url = "https://files.pythonhosted.org/packages/50/6d/0b14bb9db2989f32cd9fe7f76afedea01ee8bee3f87c07e69f24adfe7e63/greenlet-3.5.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f88193799d43dbf8c8a806d6405c9c52fe2af40bf75072a606357b33cc336c7f", size = 621541, upload-time = "2026-07-22T11:51:09.464Z" }, - { url = "https://files.pythonhosted.org/packages/57/6b/7c55ca72ef80d57c16c4a55210f82582622462dc4485799a30f4ec6f3372/greenlet-3.5.4-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:13b980043cb1b3134e81ea469da1250ddcc6bfe6d245bbaa59168d9cdc8f228f", size = 432554, upload-time = "2026-07-22T12:39:51.379Z" }, { url = "https://files.pythonhosted.org/packages/48/3d/25e9a2d9eb6b2e8b7ca4e80a3a26cb887cce6c8e0a87c921164f11bc5574/greenlet-3.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7a5f095767c4493afcd06067f2bb3b8716e3f3f9e92b99c88e7e99f885b3d4d", size = 1581444, upload-time = "2026-07-22T12:25:03.818Z" }, { url = "https://files.pythonhosted.org/packages/b9/96/4c9bf2e2c408dcc0556edce69efa9f802e82223573c53240136a086821f1/greenlet-3.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:42afdc1ab5f66da8c586c32af9224a74a706b4f0ea0dc3a4188a0860a09c65c9", size = 1645842, upload-time = "2026-07-22T11:51:12.295Z" }, { url = "https://files.pythonhosted.org/packages/b5/41/303ecb26a3a56122c0f4d4073ee078881847bd6b6f463ae0ec57ec20223b/greenlet-3.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:60149df8f462d1b230038e6590c23c3b4768bb5d6c022b3b6e82532b34b0b8a3", size = 247169, upload-time = "2026-07-22T11:38:19.893Z" }, @@ -3051,9 +3047,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/9a/e51225dcd58713f16ccbdcc501a8da21098ea14515b7870f1f94459e5ff5/greenlet-3.5.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02", size = 294831, upload-time = "2026-07-22T11:38:53.389Z" }, { url = "https://files.pythonhosted.org/packages/9f/ea/de50a50fadf979713ab18b46f22ad5ff5f2dcfc637a3ebdecf669801e1a5/greenlet-3.5.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356", size = 614619, upload-time = "2026-07-22T12:26:42.282Z" }, { url = "https://files.pythonhosted.org/packages/db/c7/2aae27fea41205b8650294c301f042a2a4bb6155eea48c995b890a92f2c1/greenlet-3.5.4-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef", size = 627021, upload-time = "2026-07-22T12:29:03.445Z" }, - { url = "https://files.pythonhosted.org/packages/1b/80/fb4d4788bbc8e54761f1fc88533af9523a6e86299fa113d6e8a8503ed9fc/greenlet-3.5.4-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07bd44616608d873d06735b63ef1a88191d6ca57c8d291d6559c71bc14c0893c", size = 632845, upload-time = "2026-07-22T12:43:45.19Z" }, { url = "https://files.pythonhosted.org/packages/eb/56/79fd826f9ccaae0b84e1b4ef68dabba5e105bb044ffcd448a0b782fcba9a/greenlet-3.5.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0", size = 624002, upload-time = "2026-07-22T11:51:11.391Z" }, - { url = "https://files.pythonhosted.org/packages/42/e3/6086fa578ebb72772722cdc4bcd628459814b42e0c2db1e3cbd6552b3271/greenlet-3.5.4-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:3529a8a933582ad19e224792cac7372489526576b75b4c124e8e4f29948f4861", size = 435053, upload-time = "2026-07-22T12:39:52.715Z" }, { url = "https://files.pythonhosted.org/packages/0a/1a/27319f97e731298513dcba1a2e91b63e9d8811d9de22130f960b129b1bf1/greenlet-3.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd", size = 1581533, upload-time = "2026-07-22T12:25:05.322Z" }, { url = "https://files.pythonhosted.org/packages/b1/6d/24240bf562e9786dd2799ee0a4a4dadb4ded22510f41b20245099159ac8c/greenlet-3.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f", size = 1645781, upload-time = "2026-07-22T11:51:14.805Z" }, { url = "https://files.pythonhosted.org/packages/c1/5a/442ab1a9ef7ca6bf7210e5397a95972206a91a31033a03c8900866a10039/greenlet-3.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c", size = 247133, upload-time = "2026-07-22T11:39:20.661Z" }, @@ -3061,9 +3055,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a5/a7/6ab1d4f9cd548d15ab90da29947f2076100130bb179b0bde59f795a459e3/greenlet-3.5.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22", size = 295410, upload-time = "2026-07-22T11:40:35.747Z" }, { url = "https://files.pythonhosted.org/packages/cd/7a/422f63b4715cbc0b24385305407adf38b48f6bb68b3e6b04090e994d0f5a/greenlet-3.5.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf", size = 661286, upload-time = "2026-07-22T12:26:43.8Z" }, { url = "https://files.pythonhosted.org/packages/d0/31/5a1cac663bf5582190c5a714ef81364f03cde232227f39748f8ae4c11da5/greenlet-3.5.4-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9", size = 673517, upload-time = "2026-07-22T12:29:04.815Z" }, - { url = "https://files.pythonhosted.org/packages/9c/bf/250c2921c7b585dde12f5239e313ca2dcbc464d161ecca36e4e6ef21762d/greenlet-3.5.4-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cef589bc65fae02d10bca2ac341191c5b33acc2967892ebf4fcbd10eabb7a74c", size = 677968, upload-time = "2026-07-22T12:43:46.788Z" }, { url = "https://files.pythonhosted.org/packages/15/4a/2a82a1e3f8aaca020853ac8d12211280ca2b231aa08ea39f636f1060c319/greenlet-3.5.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8", size = 670917, upload-time = "2026-07-22T11:51:13.589Z" }, - { url = "https://files.pythonhosted.org/packages/18/40/10bfcf6513558d82f7b95dd728001c63bd388259fe27d3e30ae01f103430/greenlet-3.5.4-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:dfc41ae893d9ceaf22c824f2153a88b30651b20e8758c2cd9ac143f23640563c", size = 480643, upload-time = "2026-07-22T12:39:54.149Z" }, { url = "https://files.pythonhosted.org/packages/68/b0/e379a152b17bfdfa95795af4049e37c0fd1b4d81f020d426db104ed07c77/greenlet-3.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3", size = 1628478, upload-time = "2026-07-22T12:25:06.678Z" }, { url = "https://files.pythonhosted.org/packages/5e/43/bffdfa64f7317f954c5c1230b5dd5922676ce198689a68c1ac1ed4b1b1a5/greenlet-3.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec", size = 1692021, upload-time = "2026-07-22T11:51:17.008Z" }, { url = "https://files.pythonhosted.org/packages/d0/11/f799f9637e2c6e9b0b716015e339040598b058cf7654dfc0d67468b177ed/greenlet-3.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c", size = 248031, upload-time = "2026-07-22T11:40:11.007Z" }, @@ -3071,18 +3063,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/69/35c62ed49c320cb4d98e14698ccca5467d3bfe683984172be9cb564d9ce3/greenlet-3.5.4-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3", size = 305571, upload-time = "2026-07-22T11:40:31.659Z" }, { url = "https://files.pythonhosted.org/packages/5b/6c/64d60216b3640dcb0b62d913dd9e0d80030c09115bb2e4ba70c95d10ca45/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867", size = 672568, upload-time = "2026-07-22T12:26:45.298Z" }, { url = "https://files.pythonhosted.org/packages/5c/de/ba3ab0a96292e53039530333b0d2ae18d9e508f3a325cd7bf15f8172944c/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c", size = 680076, upload-time = "2026-07-22T12:29:06.125Z" }, - { url = "https://files.pythonhosted.org/packages/ae/db/24a10af12bf8e639cec46c38b9ce1a282543ba42ff4fb0b31a970f1ab603/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39169a11d87a6a263afda3e9a27d1df16d0f919d40a4837cc73986c9884c0dd8", size = 681690, upload-time = "2026-07-22T12:43:48.109Z" }, { url = "https://files.pythonhosted.org/packages/a5/be/aeada79083c6f1c15f45d77a332f9c441af263ee298e3eb17522cd337d22/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66", size = 676733, upload-time = "2026-07-22T11:51:16.027Z" }, - { url = "https://files.pythonhosted.org/packages/f4/60/44a2eca7b9fd71ae0fae7ff184da1cd3169d176652b97aa1cffcbb0ef961/greenlet-3.5.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:bd3d1145f603b2db19feb9078c2e6855eb7c67e15580c010ed815cee519b86fd", size = 510263, upload-time = "2026-07-22T12:39:55.678Z" }, { url = "https://files.pythonhosted.org/packages/e9/10/2392fc3a98948652ef5fd1e7275c04f861dd13f74b78a2b4309f4ee4d090/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7", size = 1637327, upload-time = "2026-07-22T12:25:07.879Z" }, { url = "https://files.pythonhosted.org/packages/55/c6/e7237a3dfa1f205ed0d9ea1e46d70bd2811b32d516266399fb59d28ab90a/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e", size = 1697493, upload-time = "2026-07-22T11:51:19.214Z" }, { url = "https://files.pythonhosted.org/packages/55/e3/4ba8154ba2a3d43729e499f72471b4b5c993f3826d3e24da81d5f06d6572/greenlet-3.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132", size = 251637, upload-time = "2026-07-22T11:40:37.44Z" }, { url = "https://files.pythonhosted.org/packages/90/03/e3f96dfc100261a29545ddc8270cafe58f9195b6651466910e820910de77/greenlet-3.5.4-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809", size = 296076, upload-time = "2026-07-22T11:39:38.364Z" }, { url = "https://files.pythonhosted.org/packages/a4/3d/da52d208e5c977bce8667e784729e584e38b5785f4c1ec0f4c836e9a1c42/greenlet-3.5.4-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927", size = 666870, upload-time = "2026-07-22T12:26:46.691Z" }, { url = "https://files.pythonhosted.org/packages/2d/8a/7e6dee25cb8a8cf9b362c8e597cc269593378bd916f16c736c059a52e85a/greenlet-3.5.4-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5", size = 677678, upload-time = "2026-07-22T12:29:07.508Z" }, - { url = "https://files.pythonhosted.org/packages/51/a7/dafc7415d430b0a43a16396eb49ecb3b62fd720877fb259cc4dcfaf5f31e/greenlet-3.5.4-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1f17e362d78e37559e0506c5a7d066bdd45073c36a0127a543e8a0df27242ff3", size = 681428, upload-time = "2026-07-22T12:43:49.623Z" }, { url = "https://files.pythonhosted.org/packages/6c/21/5a38699fa45de749e3857d93b8f07e4c20489e77c2d35d915a2e1c456606/greenlet-3.5.4-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:394de08dad5ffcb1f50c2159d93e398d9d2da3ed437645eaa54771fa720db9f0", size = 676067, upload-time = "2026-07-22T11:51:18.163Z" }, - { url = "https://files.pythonhosted.org/packages/2e/d9/6298f3432de301d4718766cf934bd73c418c73f81fbb77247319364b0d96/greenlet-3.5.4-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:cd320d998cbaa032932830448e39abf3c6a12901295e386e8114db926e10cffb", size = 487446, upload-time = "2026-07-22T12:39:57.044Z" }, { url = "https://files.pythonhosted.org/packages/5e/ba/863116ab8ff1ca7a729e327800268939d182db47aa433db70e216e7d9194/greenlet-3.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88", size = 1633489, upload-time = "2026-07-22T12:25:09.605Z" }, { url = "https://files.pythonhosted.org/packages/fa/06/7466ced82818d6132462d7f26b3f83c66ea15d2b193a6c0088d558ed7d95/greenlet-3.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c", size = 1696584, upload-time = "2026-07-22T11:51:21.304Z" }, { url = "https://files.pythonhosted.org/packages/f9/4d/55b638489260065de9ffce606c8b5d04507bef705de4b212a0c3d6a1a0df/greenlet-3.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da", size = 248297, upload-time = "2026-07-22T11:42:04.055Z" }, @@ -3090,9 +3078,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/66/7c87ed9cdbf1d49c2c6cd1c7b9dd4d16c33b24235ca03972293a1876b30c/greenlet-3.5.4-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c", size = 306487, upload-time = "2026-07-22T11:41:25.118Z" }, { url = "https://files.pythonhosted.org/packages/5b/05/0a4201e7c0054866eefc05da234f236dd4c950d0fbf9ca0517141f01b269/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9", size = 676479, upload-time = "2026-07-22T12:26:48.129Z" }, { url = "https://files.pythonhosted.org/packages/3d/c0/4b6b8c5a3aec70f0649cd89662d120fdd6421e2bdc8e3b15c3ab5ec568d8/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25", size = 684321, upload-time = "2026-07-22T12:29:08.925Z" }, - { url = "https://files.pythonhosted.org/packages/88/15/0b167aeea95285b0e654ddce651922f666c089363c2ec528ca8b9a9ba74f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:123aa379c962ed5fe90a880327e0c3066124ac64ec99e12a238be9fd8eb3db3d", size = 685995, upload-time = "2026-07-22T12:43:50.993Z" }, { url = "https://files.pythonhosted.org/packages/24/c9/b49c31c9a972eee91e260445770e922244a7efc542697f51a012ec046d0f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f1467de1bb767f75db0aa34c195e3a496d8d1278c796e70c24ce205d3e99cde", size = 681293, upload-time = "2026-07-22T11:51:20.43Z" }, - { url = "https://files.pythonhosted.org/packages/de/90/c023ec337f32ff505be7db759c80d98f0532bb94d0c6fa13645efe9bee2e/greenlet-3.5.4-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:adf2244d7f69409925a8f22ed22cc5f93cdfe5c9dc87ff3476be2c2aaae61a05", size = 516928, upload-time = "2026-07-22T12:39:58.359Z" }, { url = "https://files.pythonhosted.org/packages/95/6f/7f2d4653770500eee667866016d42d7a68e3d3462f80df6b8e3fcd48a0eb/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8", size = 1642474, upload-time = "2026-07-22T12:25:10.819Z" }, { url = "https://files.pythonhosted.org/packages/5a/d8/8cba31036a4caae448087ba5d150660ab03b4a0f54d9150f6495a3be7262/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2", size = 1701012, upload-time = "2026-07-22T11:51:23.17Z" }, { url = "https://files.pythonhosted.org/packages/e3/cd/3f77a4cce3bae631b08eb52f53a82a976669600337e21dfdba811cb50267/greenlet-3.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2", size = 251977, upload-time = "2026-07-22T11:41:38.125Z" }, @@ -7546,27 +7532,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.72" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d5/df/656e684bafb13c1d146e7d5b5f3e7978ca177232acc84998ff36427e9462/ty-0.0.72.tar.gz", hash = "sha256:ec2b8066b618df18cab4cb8e992f8da45d360332acb23fa34df7fa29cd1b9d3a", size = 6654939, upload-time = "2026-08-14T21:35:42.612Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/3b/f51461239a4e66565d4b362f97a3b55fe7fdba2e944068341f87c62f6743/ty-0.0.72-py3-none-linux_armv6l.whl", hash = "sha256:fda86db153ffd85ee52000cf175d6a3f1c0223772cf7c5b6f726200bf92c7b44", size = 12621989, upload-time = "2026-08-14T21:35:01.676Z" }, - { url = "https://files.pythonhosted.org/packages/ca/fb/79ddf683affc679ca856f3510b5640ec3a88a842ba5f654f5d4bc78f1786/ty-0.0.72-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ceb944c612529b9023acfdc9cf4c0dcbb722549f9d17d46baecd1141baf01d7f", size = 12233910, upload-time = "2026-08-14T21:35:04.334Z" }, - { url = "https://files.pythonhosted.org/packages/5d/45/10562a0d84802158db8fa4ec46de54aa9fdcecdeeaabbfe3639ae7042b66/ty-0.0.72-py3-none-macosx_11_0_arm64.whl", hash = "sha256:108d76218333d6c092e5f1cebf8e9b06f25738613a0236a28e2dd47c936ee52c", size = 12084108, upload-time = "2026-08-14T21:35:06.686Z" }, - { url = "https://files.pythonhosted.org/packages/a1/dc/1fe1aef8d697e3509face271a5331700c7aa1d1e44a4b622707bdfa41d4b/ty-0.0.72-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f3943f186f741a2499a31053872169250c9264a9a49684920e48d8fcf4ef4f5", size = 12132640, upload-time = "2026-08-14T21:35:09.305Z" }, - { url = "https://files.pythonhosted.org/packages/14/46/41ceb265e96969487311a2014bd0e53abb4fbc1395efb2ebe411fcb4db62/ty-0.0.72-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf283c07dc3cc52ca48a3ad8ab100fb5aec3aebbd03ef6a12d5f910b8e596fc5", size = 12402489, upload-time = "2026-08-14T21:35:11.555Z" }, - { url = "https://files.pythonhosted.org/packages/2b/45/30bf43cb4fd505c5c2dd30fda27dde5f05208686cd21217adec77c954204/ty-0.0.72-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:95f3b6462c38f9f115d10cee21f47fedf715fcf2040daf36eef210359300bc7c", size = 13130835, upload-time = "2026-08-14T21:35:13.746Z" }, - { url = "https://files.pythonhosted.org/packages/31/2f/03bba754d2613f640df168335c41f83f41db150bb515839c60d80e3a7880/ty-0.0.72-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30caf658feb8ffb250d9e9e47107657a78f5f3425c227df1664d8df2ebe38880", size = 13590392, upload-time = "2026-08-14T21:35:16.839Z" }, - { url = "https://files.pythonhosted.org/packages/04/c7/03c67f00e63005ec41585653dc3096064570b1e6273742baae2798cd242f/ty-0.0.72-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:27bdc012ddfbeec8948e4a6036c0dc39ac7cf2c8ec7c7d48dc7d2fd56d57b399", size = 13309629, upload-time = "2026-08-14T21:35:19.169Z" }, - { url = "https://files.pythonhosted.org/packages/c1/df/102d3b264eb7f2a58dd11952f229bb5150bb5668d176a6154976a6675981/ty-0.0.72-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:802c5970a77d7739e6f499921fbb6984fb7ad8a31d95e1ff42fd46f3642e4f3b", size = 12734028, upload-time = "2026-08-14T21:35:22.099Z" }, - { url = "https://files.pythonhosted.org/packages/61/85/d0737c8c54d0ba67366ddfb9f31d88edf0b02299e65923e6945ae60ebcb5/ty-0.0.72-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:47dce65114fdc615c68ca0edb393b433df0956447e4267df0e264137a789598d", size = 13174832, upload-time = "2026-08-14T21:35:24.71Z" }, - { url = "https://files.pythonhosted.org/packages/1e/31/497f5a96c36d9b586ab6afe0574986835c6fd5b835a89773d2bec4711b49/ty-0.0.72-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:325144fa07e2675d0faa337fcc864213c272a499eb0cfe5bde2fdc62282d27bc", size = 12215005, upload-time = "2026-08-14T21:35:26.892Z" }, - { url = "https://files.pythonhosted.org/packages/df/7d/46e65b17b4966c7cd0140f134380d33d8e84fe6efccd761533ce793dc502/ty-0.0.72-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a5c9f15d0f58e43707d8848274be1821a0ef408eccb8aa7dda28a4a9eddf7640", size = 12421298, upload-time = "2026-08-14T21:35:29.301Z" }, - { url = "https://files.pythonhosted.org/packages/08/2a/12ada4ec17700b3cb1d4fd3bc3e5b1852df9e6885288429318cade87b3c1/ty-0.0.72-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8ee508d64b381871529cc22c412b41071bf5e908b7aa5d66a38f3f6b2573a806", size = 12669242, upload-time = "2026-08-14T21:35:31.444Z" }, - { url = "https://files.pythonhosted.org/packages/1c/1a/4692536880790fb550ed6d44a6096778dc71bb112f2c6d615cebb01a57e5/ty-0.0.72-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3699e2ec7921d44da79d6b089f7bf239b2cc53c4e45a5a38430adc34ee9e9a55", size = 12988199, upload-time = "2026-08-14T21:35:33.749Z" }, - { url = "https://files.pythonhosted.org/packages/9a/0d/f5e5a50322e9c45865e7b7a428ba6cd6527387cf0f2472492ac3cf746243/ty-0.0.72-py3-none-win32.whl", hash = "sha256:f25f72a67bd36cd247707c4784e52fad0b6b4f42a1b7dd14804110fa95c486ed", size = 11939708, upload-time = "2026-08-14T21:35:36.006Z" }, - { url = "https://files.pythonhosted.org/packages/3f/4e/8af3534b2e4214e6184a5a59c34101e94a68d578f081f97b995866bab1bf/ty-0.0.72-py3-none-win_amd64.whl", hash = "sha256:cdeee869341717e1736cea2e2d7856738c6957c320f584ed2f68c8f90100d2f5", size = 12643876, upload-time = "2026-08-14T21:35:38.141Z" }, - { url = "https://files.pythonhosted.org/packages/ff/ea/a2606e654c7276bd08586391a2525b0af3f3bf60228a8c57b2d248f273f9/ty-0.0.72-py3-none-win_arm64.whl", hash = "sha256:1bd3ac3ed4424a6d6990a85dc388556aea012bd752de21349a84b685951de0d8", size = 12394857, upload-time = "2026-08-14T21:35:40.277Z" }, +version = "0.0.75" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/d0/d0c96f898d6974a4a3569ab3efdf9512c04ad99f9203effb55f72497fe97/ty-0.0.75.tar.gz", hash = "sha256:4c5eead33dfbf6e2ebb4f400f74b51ffc9bab702a6f23ddb648a1cbb740387e3", size = 6868326, upload-time = "2026-08-26T20:23:40.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/6c/b12d03505f17581f0cfa3c12273fe34c1d67b36dfda1bc561a6bdc16512b/ty-0.0.75-py3-none-linux_armv6l.whl", hash = "sha256:e5409f50db2246fd4bd039d93d261e0cfa1daa554a4fb77256f91072c570349a", size = 12972606, upload-time = "2026-08-26T20:22:59.716Z" }, + { url = "https://files.pythonhosted.org/packages/d1/aa/30f11eecd9215a9f87e8fe8baaf48f3ce905f5d75b8e4aac70f0091f130c/ty-0.0.75-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5e7b8b3472fb9bb2eeab314984b265df08a7a9d518867a9e6020eebc06570be2", size = 12527158, upload-time = "2026-08-26T20:23:02.767Z" }, + { url = "https://files.pythonhosted.org/packages/f2/11/7fd7001b0b5c6610bfbad7357e47d5fe6f82d4e84e94c53776a478f5e9f8/ty-0.0.75-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c6ccf34169821fe0d23e3360deeef981d217963412f1d087b9bdd32ec57f7a57", size = 12400533, upload-time = "2026-08-26T20:23:04.965Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7f/1e284ea3d348d7be02f12d83bc22ed9ef193033f863f05b64db99027f141/ty-0.0.75-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:842ebb41e9c6c334b40768704e20b1a69d5c6b08805b289d5e0e2565f49f2de1", size = 12420592, upload-time = "2026-08-26T20:23:07.427Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ab/d813271543370c47fd74b5118f2066ab32b0983e907b1821f3f9a6d0fa7f/ty-0.0.75-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf7a5a723c5f1e0fab4ffbfe9bd95123a526ed48f206e5f25cb2161ca294007a", size = 12739219, upload-time = "2026-08-26T20:23:09.809Z" }, + { url = "https://files.pythonhosted.org/packages/31/5b/95b49cc5570fd92a7bf63732f649b31906158721e03c7fcb1b5be74ee3bf/ty-0.0.75-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54382f98e5da292fcd7104391afef5105c35bb2f312e29bea6f5fa419935255c", size = 13494046, upload-time = "2026-08-26T20:23:12.191Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0d/502d2dd68173cf020e1ad2bdbab9544c86776de0b0e2ed15f8c2fe006e3d/ty-0.0.75-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac13b180dc2aade2cd243f56b01650e78bf091a2e522ad3bc947245d7837c613", size = 13938899, upload-time = "2026-08-26T20:23:14.764Z" }, + { url = "https://files.pythonhosted.org/packages/20/5b/f3b12a25c07224456219fc2bd20db0ad7e40b304be0ff6aad728da0135f9/ty-0.0.75-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:752df7951a443219d7f1ff817e3723c85d428565ff449e08a7a93ba821661526", size = 13656711, upload-time = "2026-08-26T20:23:17.145Z" }, + { url = "https://files.pythonhosted.org/packages/51/7b/f090ad306e2b15a07b332d647138c5264b89d9758855ecce8b8a10bcb153/ty-0.0.75-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fd399feedf7cee816563c1baec45fc1c0b3c89f1ea42364920b688004b5b7da", size = 13093499, upload-time = "2026-08-26T20:23:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4b/f69b99aaaca0c7c65d5f114b186b26b21666f767b0c69eec99a2bdccc061/ty-0.0.75-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:d7625f6f56c7dc1e873579fdc9e432a0e21e302afe847ab60704d2303442a92e", size = 13520580, upload-time = "2026-08-26T20:23:21.789Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e7/692c5f905c0345a15d2255fc74066d660f030254ae8dcdaf33f5a5c2f279/ty-0.0.75-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:89e7d527e95a2534b70cae29e94c104b84082760ea05927d23bb87280969c104", size = 12524095, upload-time = "2026-08-26T20:23:24.026Z" }, + { url = "https://files.pythonhosted.org/packages/ba/9a/f42b12cf265ea95344bf554764c4791cfb273bdd628aadd7c209af7cadc3/ty-0.0.75-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0843f134440740706e01bee5f88f4cfc10e9b018bddb9e4ef4c12dc9fc0c9aef", size = 12756591, upload-time = "2026-08-26T20:23:26.126Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5e/9b180c133cb9cce48179a7d2bf9e1802d992aa8176a918e0e05205760b42/ty-0.0.75-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1bd0ec0e50ee1376875c88891efe6f549c3560fa5b2ddad79a425cd5a6218b9c", size = 12998754, upload-time = "2026-08-26T20:23:28.353Z" }, + { url = "https://files.pythonhosted.org/packages/39/f6/3c6ef5dd550103e29905121c67fb96a374564f31a2f44c6faa1af98c2d61/ty-0.0.75-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1f9eafd561f90110d5e29f589ec3e956c4686e2f6631348d99276436f5cbe4d1", size = 13316474, upload-time = "2026-08-26T20:23:30.857Z" }, + { url = "https://files.pythonhosted.org/packages/bb/52/12776337874c821076bd5368e352ccd9e67174790abe3b856f749cb3524b/ty-0.0.75-py3-none-win32.whl", hash = "sha256:05063a6fafe2154b794a7f964515d148e51acd186d72d4a3acd347ee9fa19336", size = 12316315, upload-time = "2026-08-26T20:23:33.528Z" }, + { url = "https://files.pythonhosted.org/packages/53/e6/bb51e16af5c7138c9f52f8f3d0a401a371c6798d092e3b74926f186a9814/ty-0.0.75-py3-none-win_amd64.whl", hash = "sha256:81cf1ba5f6b7536ad56747865214255d9bc8e80533a689dbb9ddeaad464b09f1", size = 12917267, upload-time = "2026-08-26T20:23:35.978Z" }, + { url = "https://files.pythonhosted.org/packages/39/73/4542f829107468b5de4231af67f29927c093bfad11f3c1e5b2c08fb1206b/ty-0.0.75-py3-none-win_arm64.whl", hash = "sha256:541c9af5b7a0ad23d15ec315a7da81150833c359f48124ed3789ff25eacd6f42", size = 12711024, upload-time = "2026-08-26T20:23:38.159Z" }, ] [[package]] From 93b7442f4a04f0659cf9fceb501925d92af58a43 Mon Sep 17 00:00:00 2001 From: NekoPunch <95899648+orangeCatDeveloper@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:08:17 +0000 Subject: [PATCH 48/85] docs(python): add MLflow observability example (#8085) --- .../samples/02-agents/observability/README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/python/samples/02-agents/observability/README.md b/python/samples/02-agents/observability/README.md index 201a717e784..822006b17dd 100644 --- a/python/samples/02-agents/observability/README.md +++ b/python/samples/02-agents/observability/README.md @@ -155,6 +155,23 @@ os.environ["OTEL_EXPORTER_OTLP_HEADERS"] = "" enable_sensitive_telemetry() ``` +Or with [MLflow](https://mlflow.org/docs/latest/genai/tracing/integrations/listing/microsoft-agent-framework/), which ingests traces over OTLP/HTTP at `/v1/traces` and routes them to an experiment via a header. MLflow accepts traces only, so pass a span exporter rather than a base `OTEL_EXPORTER_OTLP_ENDPOINT` (which would also aim log and metric exporters at endpoints MLflow does not serve): + +```python +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from agent_framework.observability import configure_otel_providers, enable_sensitive_telemetry + +# Start a tracking server first, e.g. `mlflow server --backend-store-uri sqlite:///mlflow.db --port 5000` +exporter = OTLPSpanExporter( + endpoint="http://localhost:5000/v1/traces", + headers={"x-mlflow-experiment-id": ""}, +) +configure_otel_providers(exporters=[exporter]) + +# Optional: opt in to capturing sensitive data +enable_sensitive_telemetry() +``` + **4. Manual setup** For full control, set up providers and exporters yourself. See [advanced_manual_setup_console_output.py](./advanced_manual_setup_console_output.py) for a complete example that sends traces, logs, and metrics to the console. The `create_resource()` helper in `agent_framework.observability` can build a resource with the appropriate service name and version from environment variables (or sensible defaults), although the sample does not use it. From 1340d2b928883e975b99f0b6266e655b9ea63277 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Mon, 7 Sep 2026 08:11:49 +0000 Subject: [PATCH 49/85] ci: adds python dependabot groups (#8065) * ci: adds python dependabot groups * ci: add pyright Dependabot group Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> * Group basic Python dependencies Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/dependabot.yml | 44 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 27995ed60da..5fd3c73b898 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -55,6 +55,28 @@ updates: schedule: interval: "weekly" day: "thursday" + groups: + basics: + patterns: + - "uv" + - "ruff" + - "prek" + - "poethepoet" + - "tomli" + - "rich" + pytest: + patterns: + - "pytest*" + flit: + patterns: + - "flit*" + python-type-checkers: + patterns: + - "mypy" + - "pyright" + - "pyrefly" + - "ty" + - "zuban" labels: - "python" - "dependencies" @@ -66,6 +88,28 @@ updates: schedule: interval: "weekly" day: "thursday" + groups: + basics: + patterns: + - "uv" + - "ruff" + - "prek" + - "poethepoet" + - "tomli" + - "rich" + pytest: + patterns: + - "pytest*" + flit: + patterns: + - "flit*" + python-type-checkers: + patterns: + - "mypy" + - "pyright" + - "pyrefly" + - "ty" + - "zuban" labels: - "python" - "dependencies" From ef712c85c6b30124be1c8a5320bedacc18533141 Mon Sep 17 00:00:00 2001 From: NekoPunch <95899648+orangeCatDeveloper@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:56:45 +0000 Subject: [PATCH 50/85] fix(python): reject MCP servers passed as provider agent tools (#7835) An MCPTool handed to ClaudeAgent or GitHubCopilotAgent was silently dropped: no error, no warning, and none of its tools reached the model. Both SDKs connect to MCP servers themselves and take their own mcp_servers config, so the framework cannot honor a framework-managed MCPTool there. It is now refused with the native configuration to write instead. --- .../claude/agent_framework_claude/_agent.py | 22 ++++++++-- .../claude/tests/test_claude_agent.py | 28 +++++++++++- .../agent_framework_github_copilot/_agent.py | 23 +++++++++- .../tests/test_github_copilot_agent.py | 44 +++++++++++++++++++ 4 files changed, 112 insertions(+), 5 deletions(-) diff --git a/python/packages/claude/agent_framework_claude/_agent.py b/python/packages/claude/agent_framework_claude/_agent.py index e34f507c07f..bbdc3305a44 100644 --- a/python/packages/claude/agent_framework_claude/_agent.py +++ b/python/packages/claude/agent_framework_claude/_agent.py @@ -29,6 +29,7 @@ normalize_messages, normalize_tools, ) +from agent_framework._mcp import MCPTool from agent_framework._telemetry import mark_feature_used from agent_framework.exceptions import AgentException, AgentInvalidRequestException from agent_framework.observability import AgentTelemetryLayer @@ -72,6 +73,15 @@ logger = logging.getLogger("agent_framework.claude") +_MCP_TOOL_MESSAGE = ( + "MCP server '{name}' cannot be passed to ClaudeAgent as a tool: the Claude Agent SDK " + "connects to MCP servers itself, so a framework-managed MCPTool would keep none of its " + "framework behavior. Configure the server natively instead, for example " + "default_options={{'mcp_servers': {{'{name}': " + "{{'type': 'stdio', 'command': 'python', 'args': ['server.py']}}}}}}, or use a ChatAgent, " + "where the framework owns the connection." +) + FINISH_REASON_MAP: dict[str, str] = { "end_turn": "stop", "stop_sequence": "stop", @@ -408,8 +418,9 @@ def _normalize_tools( return non_builtin_tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] = [] - if not isinstance(tools, list): - tools = [tools] + # Same wrapping rule as normalize_tools: any other Sequence is a collection of tools. + if isinstance(tools, (str, bytes, bytearray, Mapping)) or not isinstance(tools, Sequence): + tools = [tools] # type: ignore[assignment] for tool in tools: # type: ignore[reportUnknownVariableType] if isinstance(tool, str): self._builtin_tools.append(tool) @@ -417,7 +428,12 @@ def _normalize_tools( non_builtin_tools.append(tool) # type: ignore[union-attr, reportUnknownArgumentType] if not non_builtin_tools: return - self._custom_tools.extend(normalize_tools(non_builtin_tools)) + # Check after normalizing: it flattens tool-collection wrappers, which can hide an MCPTool. + normalized = normalize_tools(non_builtin_tools) + for tool in normalized: + if isinstance(tool, MCPTool): + raise TypeError(_MCP_TOOL_MESSAGE.format(name=tool.name)) + self._custom_tools.extend(normalized) async def __aenter__(self) -> RawClaudeAgent[OptionsT]: """Start the agent when entering async context.""" diff --git a/python/packages/claude/tests/test_claude_agent.py b/python/packages/claude/tests/test_claude_agent.py index 38359f9d152..6fdb444f825 100644 --- a/python/packages/claude/tests/test_claude_agent.py +++ b/python/packages/claude/tests/test_claude_agent.py @@ -1,10 +1,11 @@ # Copyright (c) Microsoft. All rights reserved. +from types import SimpleNamespace from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch import pytest -from agent_framework import AgentResponseUpdate, AgentSession, Content, Message, tool +from agent_framework import AgentResponseUpdate, AgentSession, Content, MCPStdioTool, Message, tool from agent_framework._settings import load_settings from agent_framework.exceptions import AgentInvalidRequestException @@ -174,6 +175,31 @@ def farewell(name: str) -> str: agent = ClaudeAgent(tools=[greet, farewell]) assert len(agent._custom_tools) == 2 # type: ignore[reportPrivateUsage] + def test_mcp_tool_is_rejected_with_the_native_configuration(self) -> None: + """An MCP server cannot keep its framework behavior here, so it is refused, not dropped.""" + with pytest.raises(TypeError, match="mcp_servers"): + ClaudeAgent(tools=[MCPStdioTool(name="weather", command="python")]) + + def test_mcp_tool_in_a_tuple_is_rejected(self) -> None: + """``tools`` takes any sequence, so a tuple must not slip past the refusal.""" + with pytest.raises(TypeError, match="mcp_servers"): + ClaudeAgent(tools=(MCPStdioTool(name="weather", command="python"),)) + + def test_mcp_tool_inside_a_tool_collection_is_rejected(self) -> None: + """normalize_tools flattens collection wrappers, so the refusal has to run after it.""" + + toolbox = SimpleNamespace(tools=[MCPStdioTool(name="weather", command="python")]) + + with pytest.raises(TypeError, match="mcp_servers"): + ClaudeAgent(tools=[toolbox]) + + def test_builtin_tools_in_a_tuple_are_recognized(self) -> None: + """A tuple of built-in names must classify like the list form, not fall through as custom tools.""" + agent = ClaudeAgent(tools=("Read", "Bash")) + + assert agent._builtin_tools == ["Read", "Bash"] # type: ignore[reportPrivateUsage] + assert agent._custom_tools == [] # type: ignore[reportPrivateUsage] + def test_no_tools(self) -> None: """Test agent without tools.""" agent = ClaudeAgent() diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index c58e5edf711..811d329891f 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -30,6 +30,7 @@ add_usage_details, normalize_messages, ) +from agent_framework._mcp import MCPTool from agent_framework._settings import load_settings from agent_framework._telemetry import mark_feature_used from agent_framework._tools import FunctionTool, ToolTypes @@ -179,6 +180,22 @@ async def _resolve_function_approval( logger = logging.getLogger("agent_framework.github_copilot") +_MCP_TOOL_MESSAGE = ( + "MCP server '{name}' cannot be passed to GitHubCopilotAgent as a tool: the Copilot SDK " + "connects to MCP servers itself, so a framework-managed MCPTool would keep none of its " + "framework behavior. Configure the server natively instead, for example " + "default_options={{'mcp_servers': {{'{name}': {{'type': 'stdio', 'command': 'python', " + "'args': ['server.py'], 'tools': ['*']}}}}}}, or use a ChatAgent, where the framework owns " + "the connection." +) + + +def _reject_mcp_tools(tools: Sequence[Any]) -> None: + """Refuse MCP servers handed in as tools, from whichever option carried them.""" + for tool in tools: + if isinstance(tool, MCPTool): + raise TypeError(_MCP_TOOL_MESSAGE.format(name=tool.name)) + def _deny_all_permissions( _request: PermissionRequest, @@ -654,6 +671,7 @@ def __init__( ) self._tools = normalize_tools(tools) + _reject_mcp_tools(self._tools) self._permission_handler = on_permission_request self._on_pre_tool_use: PreToolUseHandler | None = on_pre_tool_use self._function_approval_handler: FunctionApprovalCallback | None = on_function_approval @@ -1472,7 +1490,10 @@ def _build_session_kwargs( # Merge agent-level tools with any caller-supplied tools (from default_options # or per-run options, the latter winning) and convert to SDK tools. - all_tools = list(self._tools or []) + list(kwargs.get("tools") or []) + # Normalize the option-supplied tools the way the constructor does: it converts callables + # and flattens tool-collection wrappers, which can otherwise hide an MCPTool. + all_tools = normalize_tools(list(self._tools or []) + list(kwargs.get("tools") or [])) + _reject_mcp_tools(all_tools) kwargs["tools"] = self._prepare_tools(all_tools) if all_tools else None kwargs["streaming"] = streaming diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index 3643ae9e591..37412e42048 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -9,6 +9,7 @@ import unittest.mock from collections.abc import Sequence from datetime import datetime, timezone +from types import SimpleNamespace from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 @@ -24,6 +25,7 @@ Content, ContextProvider, HistoryProvider, + MCPStdioTool, Message, tool, ) @@ -1940,6 +1942,48 @@ async def test_arbitrary_option_forwarded_verbatim( assert config["reasoning_effort"] == "high" assert config["context_tier"] == "large" + async def test_mcp_tool_is_rejected_with_the_native_configuration( + self, + mock_client: MagicMock, + ) -> None: + """An MCP server cannot keep its framework behavior here, so it is refused, not dropped.""" + with pytest.raises(TypeError, match="mcp_servers"): + GitHubCopilotAgent(client=mock_client, tools=[MCPStdioTool(name="weather", command="python")]) + + async def test_mcp_tool_in_a_tuple_is_rejected( + self, + mock_client: MagicMock, + ) -> None: + """``tools`` takes any sequence, so a tuple must not slip past the refusal.""" + with pytest.raises(TypeError, match="mcp_servers"): + GitHubCopilotAgent(client=mock_client, tools=(MCPStdioTool(name="weather", command="python"),)) + + async def test_mcp_tool_from_default_options_is_rejected( + self, + mock_client: MagicMock, + ) -> None: + """Tools reach the SDK from the options too, so the refusal cannot live in the constructor alone.""" + agent = GitHubCopilotAgent( + client=mock_client, + default_options=cast(Any, {"tools": [MCPStdioTool(name="weather", command="python")]}), + ) + await agent.start() + + with pytest.raises(AgentException, match="mcp_servers"): + await agent._get_or_create_session(AgentSession()) # type: ignore[reportPrivateUsage] + + async def test_mcp_tool_inside_a_tool_collection_from_options_is_rejected( + self, + mock_client: MagicMock, + ) -> None: + """Option-supplied tools are normalized too, so a wrapper cannot hide an MCP server.""" + toolbox = SimpleNamespace(tools=[MCPStdioTool(name="weather", command="python")]) + agent = GitHubCopilotAgent(client=mock_client, default_options=cast(Any, {"tools": [toolbox]})) + await agent.start() + + with pytest.raises(AgentException, match="mcp_servers"): + await agent._get_or_create_session(AgentSession()) # type: ignore[reportPrivateUsage] + async def test_tools_from_default_options_are_honored( self, mock_client: MagicMock, From 90d8f6f7f4959a5fcd60e4a67c3afa727f48f03e Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:53:55 +0000 Subject: [PATCH 51/85] Python: Scope MCP headers to transport requests (#8039) * Python: Scope MCP HTTP headers per tool Keep provider header processing associated with the MCP tool that issued each request, including when HTTP clients are shared. Clean up request hooks on close and preserve redirect handling. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4fca7430-5806-4fb4-a190-729e2473c5bb * Python: Refresh MCP headers across redirects Remove previously injected provider headers before applying the latest set so redirected requests retain only current provider output. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4fca7430-5806-4fb4-a190-729e2473c5bb * Python: Defer MCP hook cleanup until teardown Keep the request hook attached while lifecycle-owner transport cleanup runs, including when the caller awaiting close is cancelled. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4fca7430-5806-4fb4-a190-729e2473c5bb * Python: Preserve active MCP hook iteration Replace the shared request-hook list during cleanup so in-flight HTTPX hook iteration is not disrupted. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4fca7430-5806-4fb4-a190-729e2473c5bb --------- Copilot-Session: 4fca7430-5806-4fb4-a190-729e2473c5bb --- python/packages/core/AGENTS.md | 1 + python/packages/core/agent_framework/_mcp.py | 80 ++++- python/packages/core/tests/core/test_mcp.py | 334 +++++++++++++++++-- 3 files changed, 387 insertions(+), 28 deletions(-) diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index 7182615705d..1e90594f3c7 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -121,6 +121,7 @@ The vector store API is experimental under the shared `VECTOR_STORES` feature ID - **`allowed_tools`** (constructor arg on all `MCPTool` subclasses) - Restricts exposed MCP tools by raw remote MCP tool identity. Prefixed local names remain accepted only when the raw remote name already matches its normalized form; normalized/local aliases do not authorize a different raw remote name. If multiple raw remote tool names map to the same local function name, tool loading raises `ToolExecutionException` instead of first-one-wins shadowing. - **Progressive MCP disclosure** (`use_progressive_disclosure`, `always_load`) - When enabled on any `MCPTool` subclass, the initial model-facing surface is loader tools (`list_mcp_tools` / `load_tool` / `unload_tool`, prefixed by `tool_name_prefix` when configured) plus allowed tools selected by `always_load` and tools loaded earlier on the same `MCPTool` instance. `list_mcp_tools` only reports tools that pass `allowed_tools`; filtered tools are not listed or loadable. Loader tool names are reserved in progressive mode: remote MCP tools whose local generated name collides with a loader name are omitted from the initial/listed surface, and explicit `load_tool` calls return a model-visible message pointing callers to `tool_name_prefix` or excluding the colliding tool. `load_tool` accepts one tool name or a list of tool names and uses `FunctionInvocationContext.add_tools(...)` so the selected generated MCP `FunctionTool`s become available on the next function-calling iteration while keeping existing approval mode, argument filtering, header-provider runtime kwargs, result parsing, OTel, and task behavior. `unload_tool` accepts one dynamically loaded tool name or a list of names and removes them from the live tool list and persisted progressive surface, but it does not remove tools configured in `always_load`. Invalid `always_load` entries are ignored like unmatched `allowed_tools` entries. - **`additional_tool_argument_names`** (constructor arg on all `MCPTool` subclasses) - Opt extra argument names back into the allowlist. Accepts a `Sequence[str]` (applied to every tool) or a `Mapping[str, Sequence[str]]` keyed by **remote tool name**, where the reserved key `"*"` denotes global extras. It is configured only in user code at construction; there is **no per-call/runtime override**, so a model-issued tool call cannot change which names pass through — but note this constrains the *model*, not the *server*, which still widens the effective allowlist through its schema. To use a server that accepts `additionalProperties: true`, list the extra names here and then either (1) manually extend that tool's `inputSchema` (via the `.functions` list after connecting) so the model is prompted to supply them, or (2) supply the values yourself via `function_invocation_kwargs`. If a normal forwarded argument name is supplied by both the model and `function_invocation_kwargs`, the model-supplied value wins; `_meta` is the exception and only trusted runtime/caller metadata is used. +- **`header_provider` request scoping** - When sharing an `http_client`, keep provider processing scoped to the originating `MCPStreamableHTTPTool`, remove its request hook on `close()`, and strip injected headers from cross-origin redirects. - **`function_invocation_kwargs` and MCP servers** - That dict is shared across every tool in the run, including every attached `MCPTool`, and any name in it reaches a server that declares a matching `inputSchema` property. `header_provider` does not mitigate this — it reads the kwargs without consuming them. To keep a credential out of tool arguments, source it outside `function_invocation_kwargs`: read a `ContextVar` inside the provider (this still allows a different value per request), configure a custom `http_client`, or use `env` for `MCPStdioTool`. - **Sampling guardrails** (`sampling_callback`) - Passing `client=` advertises `SamplingCapability` so the server can send `sampling/createMessage`. Because remote servers are untrusted (confused-deputy risk), the default `sampling_callback` is **deny-by-default** and applies, in order: a per-session rate limit (`sampling_max_requests`, default `_DEFAULT_SAMPLING_MAX_REQUESTS`), an approval gate (`sampling_approval_callback`), and a `maxTokens` cap (`sampling_max_tokens`, default `_DEFAULT_SAMPLING_MAX_TOKENS`). The approval callback (constructor arg on all subclasses; exported type alias `SamplingApprovalCallback`) receives the raw `CreateMessageRequestParams`, may be sync or async, and must return truthy to approve. When it is `None` (the default) every sampling request is denied; pass `lambda params: True` to restore legacy auto-approve as an explicit opt-in. Requests and denials are logged at WARNING (content is not logged). The per-session counter resets in `_reset_session_state`. - **`MCPTaskOptions`** (experimental, `MCP_LONG_RUNNING_TASKS` feature, **frozen**) - Per-tool-instance options controlling the SEP-2663 long-running task lifecycle. When the server advertises a tool with `execution.taskSupport == "required"`, `MCPTool.call_tool` transparently routes through `call_tool_as_task`, which sends an augmented `tools/call`, polls `tasks/get` until terminal, and reinterprets `tasks/result` as a normal `CallToolResult`. Instances are immutable; replace via `MCPTool.task_options = MCPTaskOptions(...)`. Fields: diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 399637dfcef..a1604eab471 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -124,9 +124,43 @@ class MCPSpecificApproval(TypedDict, total=False): "_meta", }) _mcp_call_headers: contextvars.ContextVar[dict[str, str]] = contextvars.ContextVar("_mcp_call_headers") +_MCP_HEADER_OWNER_EXTENSION = "agent_framework.mcp_header_owner" +_MCP_INJECTED_HEADER_KEYS_EXTENSION = "agent_framework.mcp_injected_header_keys" MCP_DEFAULT_TIMEOUT = 30 MCP_DEFAULT_SSE_READ_TIMEOUT = 60 * 5 + +class _MCPHeaderScopedClient: + """Attach private tool context to MCP transport requests.""" + + def __init__(self, client: AsyncClient, owner: object) -> None: + self._client = client + self._owner = owner + + def __getattr__(self, name: str) -> Any: + # Delegate the rest of the httpx client surface so this wrapper stays a + # drop-in for the MCP transport. Only the request-sending methods below + # are wrapped; anything the transport reads (timeouts, headers, ...) + # comes straight from the caller's client. ``_client`` itself is always a + # real instance attribute, so guard against recursing on a partially + # initialized wrapper. + if name == "_client": + raise AttributeError(name) + return getattr(self._client, name) + + def _tagged_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]: + extensions = dict(kwargs.get("extensions") or {}) + extensions[_MCP_HEADER_OWNER_EXTENSION] = self._owner + kwargs["extensions"] = extensions + return kwargs + + def stream(self, *args: Any, **kwargs: Any) -> Any: + return self._client.stream(*args, **self._tagged_kwargs(kwargs)) + + async def delete(self, *args: Any, **kwargs: Any) -> Any: + return await self._client.delete(*args, **self._tagged_kwargs(kwargs)) + + # Default safety limits applied to server-initiated MCP sampling requests # (``sampling/createMessage``). MCP servers are untrusted third parties, so the # default ``sampling_callback`` denies requests unless an approval callback is @@ -3043,7 +3077,7 @@ def __init__( Note: The arguments are used to create a streamable HTTP client using the new ``mcp.client.streamable_http.streamable_http_client`` API. - If an asyncClient is provided via ``http_client``, it will be used directly. + If an asyncClient is provided via ``http_client``, it will be used as the underlying transport client. Otherwise, the ``streamable_http_client`` API will create and manage a default client. Args: @@ -3120,9 +3154,12 @@ def __init__( agent middleware) without creating a separate ``httpx.AsyncClient``. The framework attaches these headers only to requests whose origin (scheme, host, port) matches the configured ``url``, so they are not leaked to other - origins on cross-origin redirects. If you instead supply sensitive headers + origins on cross-origin redirects; headers injected this way are also removed + again if a redirect leaves that origin. If you instead supply sensitive headers through a custom ``http_client``, you must enforce this same origin-scoped policy yourself. + Headers returned by the provider are applied only to requests issued by this + tool, including when several tools share one ``http_client``. Note that the provider reads these kwargs without consuming them: the same values continue on to the outbound argument filter, so reading a credential here does not withhold it from the server. See @@ -3190,6 +3227,8 @@ def __init__( # otherwise overwrite each other's snapshot and attach the wrong per-call headers. self._active_call_headers: dict[str, str] | None = None self._call_headers_lock = asyncio.Lock() + self._header_request_owner = object() + self._header_hook_client: AsyncClient | None = None def _mcp_base_span_attributes(self) -> dict[str, Any]: attrs = super()._mcp_base_span_attributes() @@ -3230,7 +3269,12 @@ def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: if not hasattr(self, "_inject_headers_hook"): async def _inject_headers(request: Request) -> None: # ruff:ignore[unused-async] + request_owner = request.extensions.get(_MCP_HEADER_OWNER_EXTENSION) + if request_owner is not self._header_request_owner: + return if _url_origin(request.url) != target_origin: + for key in request.extensions.pop(_MCP_INJECTED_HEADER_KEYS_EXTENSION, ()): + request.headers.pop(key, None) return # The transport may send this request from a task whose context was # captured before call_tool set the ContextVar; fall back to the @@ -3264,18 +3308,48 @@ async def _inject_headers(request: Request) -> None: # ruff:ignore[unused-async exc_info=True, ) headers = {} + for key in request.extensions.pop(_MCP_INJECTED_HEADER_KEYS_EXTENSION, ()): + request.headers.pop(key, None) for key, value in headers.items(): request.headers[key] = value + request.extensions[_MCP_INJECTED_HEADER_KEYS_EXTENSION] = tuple(headers) self._inject_headers_hook = _inject_headers + + if self._header_hook_client is not http_client: + self._remove_header_hook() + self._header_hook_client = http_client + if self._inject_headers_hook not in http_client.event_hooks["request"]: http_client.event_hooks["request"].append(self._inject_headers_hook) + transport_http_client = ( + _MCPHeaderScopedClient(http_client, self._header_request_owner) if http_client is not None else None + ) + return streamable_http_client( url=self.url, - http_client=http_client, + http_client=transport_http_client, terminate_on_close=self.terminate_on_close if self.terminate_on_close is not None else True, ) + def _remove_header_hook(self) -> None: + """Detach this tool's request hook from its HTTP client.""" + if self._header_hook_client is None or not hasattr(self, "_inject_headers_hook"): + return + request_hooks = self._header_hook_client.event_hooks["request"] + if self._inject_headers_hook in request_hooks: + self._header_hook_client.event_hooks["request"] = [ + hook for hook in request_hooks if hook is not self._inject_headers_hook + ] + self._header_hook_client = None + + async def _close_on_owner(self) -> None: + """Disconnect on the lifecycle owner before removing the request hook.""" + try: + await super()._close_on_owner() + finally: + self._remove_header_hook() + async def call_tool(self, tool_name: str, **kwargs: Any) -> str | list[Content]: """Call a tool, injecting headers from the header_provider if configured. diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index ee52c3eaf4d..f93727edb28 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -31,6 +31,7 @@ ) from agent_framework._feature_stage import _WARNED_FEATURES, ExperimentalFeature, ExperimentalWarning from agent_framework._mcp import ( + _MCP_HEADER_OWNER_EXTENSION, MCPTool, _build_prefixed_mcp_name, _describe_error, @@ -65,6 +66,16 @@ def _reset_progressive_mcp_warning_state() -> None: _WARNED_FEATURES.discard((ExperimentalWarning, ExperimentalFeature.PROGRESSIVE_TOOLS.value)) +def _request_for_mcp_tool(tool: MCPStreamableHTTPTool, url: str = "http://example.com/mcp") -> Any: + import httpx + + return httpx.Request( + "POST", + url, + extensions={_MCP_HEADER_OWNER_EXTENSION: tool._header_request_owner}, + ) + + # Helper function tests def test_normalize_mcp_name(): """Test MCP name normalization.""" @@ -4070,7 +4081,7 @@ async def test_load_prompts_prevents_multiple_calls(): async def test_mcp_streamable_http_tool_httpx_client_cleanup(): - """Test that MCPStreamableHTTPTool properly passes through httpx clients.""" + """Test that MCPStreamableHTTPTool delegates to caller-provided httpx clients.""" from unittest.mock import AsyncMock, Mock, patch from agent_framework import MCPStreamableHTTPTool @@ -4120,10 +4131,10 @@ async def test_mcp_streamable_http_tool_httpx_client_cleanup(): # Verify the user-provided client was stored assert tool2._httpx_client is user_client, "User-provided client should be stored" - # Verify streamable_http_client was called with the user's client + # Verify the transport wrapper delegates to the user's client. # Get the last call (should be from tool2.connect()) call_args = mock_client.call_args - assert call_args.kwargs["http_client"] is user_client, "User's client should be passed through" + assert call_args.kwargs["http_client"]._client is user_client async def test_load_tools_with_pagination(): @@ -6135,8 +6146,6 @@ def get_mcp_client(self): # pyrefly: ignore[bad-override] async def test_mcp_streamable_http_tool_header_provider_with_httpx_event_hook(): """Test that the httpx event hook injects headers from the contextvar.""" - import httpx - from agent_framework._mcp import MCP_DEFAULT_SSE_READ_TIMEOUT, MCP_DEFAULT_TIMEOUT, _mcp_call_headers tool = MCPStreamableHTTPTool( @@ -6161,7 +6170,7 @@ async def test_mcp_streamable_http_tool_header_provider_with_httpx_event_hook(): # Simulate what happens during a call_tool: contextvar is set token = _mcp_call_headers.set({"X-Custom": "test-value"}) try: - request = httpx.Request("POST", "http://example.com/mcp") + request = _request_for_mcp_tool(tool) await hooks[0](request) assert request.headers.get("X-Custom") == "test-value" finally: @@ -6179,8 +6188,6 @@ async def test_mcp_streamable_http_tool_header_provider_injects_on_ambient_reque outside call_tool, so the contextvar/snapshot are unset. A static header_provider should still be invoked (with empty kwargs) so these requests carry auth headers. """ - import httpx - tool = MCPStreamableHTTPTool( name="test", url="http://example.com/mcp", @@ -6196,7 +6203,7 @@ async def test_mcp_streamable_http_tool_header_provider_injects_on_ambient_reque assert len(hooks) == 1 # No contextvar set and no active call snapshot: simulates the initialize handshake. - request = httpx.Request("POST", "http://example.com/mcp") + request = _request_for_mcp_tool(tool) await hooks[0](request) assert request.headers.get("Authorization") == "******" finally: @@ -6211,8 +6218,6 @@ async def test_mcp_streamable_http_tool_header_provider_ambient_request_tolerate time raise KeyError. The hook should swallow that specific error and proceed without headers rather than failing the initialize handshake. """ - import httpx - tool = MCPStreamableHTTPTool( name="test", url="http://example.com/mcp", @@ -6228,7 +6233,7 @@ async def test_mcp_streamable_http_tool_header_provider_ambient_request_tolerate assert len(hooks) == 1 # No kwargs available at connect time -> provider raises KeyError -> hook swallows it. - request = httpx.Request("POST", "http://example.com/mcp") + request = _request_for_mcp_tool(tool) await hooks[0](request) assert "Authorization" not in request.headers finally: @@ -6244,8 +6249,6 @@ async def test_mcp_streamable_http_tool_header_provider_empty_active_call_skips_ rather than as "unset", which would re-invoke header_provider({}) mid-call and inject headers the caller deliberately omitted. """ - import httpx - from agent_framework._mcp import _mcp_call_headers call_count = 0 @@ -6271,7 +6274,7 @@ def provider(kw: dict[str, Any]) -> dict[str, str]: tool._active_call_headers = {} try: call_count = 0 - request = httpx.Request("POST", "http://example.com/mcp") + request = _request_for_mcp_tool(tool) await hooks[0](request) assert "Authorization" not in request.headers assert call_count == 0, "ambient fallback must not run during a set-but-empty call" @@ -6292,8 +6295,6 @@ async def test_mcp_streamable_http_tool_header_provider_ambient_kwarg_error_is_b """ import logging - import httpx - tool = MCPStreamableHTTPTool( name="test", url="http://example.com/mcp", @@ -6310,7 +6311,7 @@ async def test_mcp_streamable_http_tool_header_provider_ambient_kwarg_error_is_b with caplog.at_level(logging.DEBUG, logger="agent_framework._mcp"): for _ in range(3): - request = httpx.Request("POST", "http://example.com/mcp") + request = _request_for_mcp_tool(tool) await hooks[0](request) assert "Authorization" not in request.headers @@ -6328,7 +6329,6 @@ async def test_mcp_streamable_http_tool_header_provider_ambient_non_keyerror_pro failure or a provider bug - propagates so it is not silently converted into unauthenticated traffic, matching the call_tool path which does not catch header_provider exceptions. """ - import httpx class TokenRefreshError(RuntimeError): pass @@ -6347,7 +6347,7 @@ def failing_provider(kw: dict[str, Any]) -> dict[str, str]: assert len(hooks) == 1 with pytest.raises(TokenRefreshError): - await hooks[0](httpx.Request("POST", "http://example.com/mcp")) + await hooks[0](_request_for_mcp_tool(tool)) finally: if getattr(tool, "_httpx_client", None) is not None: await tool._httpx_client.aclose() # type: ignore[union-attr] @@ -6362,7 +6362,10 @@ async def test_mcp_streamable_http_tool_header_provider_skips_cross_origin_redir tool = MCPStreamableHTTPTool( name="test", url="http://example.com/mcp", - header_provider=lambda kw: {"Authorization": f"Bearer {kw.get('token', '')}"}, + header_provider=lambda kw: { + "Authorization": f"Bearer {kw.get('token', '')}", + "X-API-Key": kw.get("api_key", ""), + }, ) try: @@ -6373,15 +6376,22 @@ async def test_mcp_streamable_http_tool_header_provider_skips_cross_origin_redir hooks = tool._httpx_client.event_hooks.get("request", []) assert len(hooks) == 1 - token = _mcp_call_headers.set({"Authorization": "Bearer secret"}) + token = _mcp_call_headers.set({"Authorization": "Bearer secret", "X-API-Key": "api-secret"}) try: - same_origin = httpx.Request("POST", "http://example.com/redirected") + same_origin = _request_for_mcp_tool(tool, "http://example.com/redirected") await hooks[0](same_origin) assert same_origin.headers.get("Authorization") == "Bearer secret" + assert same_origin.headers.get("X-API-Key") == "api-secret" - cross_origin = httpx.Request("POST", "http://attacker.example/capture") + cross_origin = httpx.Request( + "POST", + "http://attacker.example/capture", + headers=same_origin.headers, + extensions=same_origin.extensions, + ) await hooks[0](cross_origin) assert "Authorization" not in cross_origin.headers + assert "X-API-Key" not in cross_origin.headers finally: _mcp_call_headers.reset(token) finally: @@ -6389,6 +6399,54 @@ async def test_mcp_streamable_http_tool_header_provider_skips_cross_origin_redir await tool._httpx_client.aclose() # type: ignore[union-attr] +async def test_mcp_streamable_http_tool_replaces_headers_on_same_origin_redirect(): + """A redirected request must retain only the provider's latest header set.""" + import httpx + + provider_headers = {"X-Previous": "old"} + tool = MCPStreamableHTTPTool( + name="test", + url="http://example.com/mcp", + header_provider=lambda _kw: provider_headers, + ) + + try: + with patch("agent_framework._mcp.streamable_http_client"): + tool.get_mcp_client() + + assert tool._httpx_client is not None + hooks = tool._httpx_client.event_hooks.get("request", []) + assert len(hooks) == 1 + + initial = _request_for_mcp_tool(tool, "http://example.com/start") + await hooks[0](initial) + assert initial.headers.get("X-Previous") == "old" + + provider_headers = {"X-Current": "new"} + same_origin_redirect = httpx.Request( + "POST", + "http://example.com/redirected", + headers=initial.headers, + extensions=initial.extensions, + ) + await hooks[0](same_origin_redirect) + assert "X-Previous" not in same_origin_redirect.headers + assert same_origin_redirect.headers.get("X-Current") == "new" + + cross_origin_redirect = httpx.Request( + "POST", + "http://other.example/redirected", + headers=same_origin_redirect.headers, + extensions=same_origin_redirect.extensions, + ) + await hooks[0](cross_origin_redirect) + assert "X-Previous" not in cross_origin_redirect.headers + assert "X-Current" not in cross_origin_redirect.headers + finally: + if getattr(tool, "_httpx_client", None) is not None: + await tool._httpx_client.aclose() # type: ignore[union-attr] + + async def test_mcp_streamable_http_tool_header_provider_with_user_httpx_client(): """Test that header_provider works when the user provides their own httpx client.""" import httpx @@ -6415,7 +6473,7 @@ async def test_mcp_streamable_http_tool_header_provider_with_user_httpx_client() # Verify the hook injects headers token = _mcp_call_headers.set({"X-Dynamic": "per-request"}) try: - request = httpx.Request("POST", "http://example.com/mcp") + request = _request_for_mcp_tool(tool) await hooks[0](request) assert request.headers.get("X-Dynamic") == "per-request" finally: @@ -6424,6 +6482,232 @@ async def test_mcp_streamable_http_tool_header_provider_with_user_httpx_client() await user_client.aclose() +async def test_mcp_streamable_http_tool_header_provider_isolated_on_shared_httpx_client(): + """Each MCP transport must use its own headers when sharing an httpx client.""" + import httpx + + captured_headers: list[dict[str, str]] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + captured_headers.append({key.lower(): value for key, value in request.headers.items()}) + return httpx.Response(200) + + user_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + tool_a = MCPStreamableHTTPTool( + name="a", + url="http://example.com/mcp", + http_client=user_client, + header_provider=lambda _kw: {"Authorization": "Bearer A", "X-Principal-A": "present"}, + ) + tool_b = MCPStreamableHTTPTool( + name="b", + url="http://example.com/mcp", + http_client=user_client, + header_provider=lambda _kw: {"Authorization": "Bearer B", "X-Principal-B": "present"}, + ) + tool_without_provider = MCPStreamableHTTPTool( + name="anonymous", + url="http://example.com/mcp", + http_client=user_client, + ) + + try: + with patch("agent_framework._mcp.streamable_http_client") as mock_transport: + tool_a.get_mcp_client() + client_a = mock_transport.call_args.kwargs["http_client"] + tool_b.get_mcp_client() + client_b = mock_transport.call_args.kwargs["http_client"] + tool_without_provider.get_mcp_client() + client_without_provider = mock_transport.call_args.kwargs["http_client"] + + for client in (client_a, client_b, client_without_provider, client_a): + async with client.stream("POST", "http://example.com/mcp"): + pass + + assert captured_headers[0].get("authorization") == "Bearer A" + assert captured_headers[0].get("x-principal-a") == "present" + assert "x-principal-b" not in captured_headers[0] + assert captured_headers[1].get("authorization") == "Bearer B" + assert captured_headers[1].get("x-principal-b") == "present" + assert "x-principal-a" not in captured_headers[1] + assert "authorization" not in captured_headers[2] + assert "x-principal-a" not in captured_headers[2] + assert "x-principal-b" not in captured_headers[2] + assert captured_headers[3].get("authorization") == "Bearer A" + assert captured_headers[3].get("x-principal-a") == "present" + assert "x-principal-b" not in captured_headers[3] + finally: + await user_client.aclose() + + +async def test_mcp_streamable_http_tool_removes_header_hook_on_close(): + """Closing one tool must remove only its hook, and reconnecting must restore it.""" + import httpx + + user_client = httpx.AsyncClient() + tool_a = MCPStreamableHTTPTool( + name="a", + url="http://example.com/mcp", + http_client=user_client, + header_provider=lambda _kw: {"Authorization": "Bearer A"}, + ) + tool_b = MCPStreamableHTTPTool( + name="b", + url="http://example.com/mcp", + http_client=user_client, + header_provider=lambda _kw: {"Authorization": "Bearer B"}, + ) + + try: + with patch("agent_framework._mcp.streamable_http_client"): + tool_a.get_mcp_client() + tool_b.get_mcp_client() + assert len(user_client.event_hooks["request"]) == 2 + + await tool_a.close() + assert user_client.event_hooks["request"] == [tool_b._inject_headers_hook] + + # Reconnecting after close re-attaches exactly one hook for tool A. + with patch("agent_framework._mcp.streamable_http_client"): + tool_a.get_mcp_client() + tool_a.get_mcp_client() + assert user_client.event_hooks["request"].count(tool_a._inject_headers_hook) == 1 + assert len(user_client.event_hooks["request"]) == 2 + finally: + await user_client.aclose() + + +async def test_mcp_streamable_http_tool_removes_hook_without_mutating_active_hook_list(): + """Closing one tool must not disrupt an in-progress iteration over shared hooks.""" + import httpx + + from agent_framework._mcp import _MCP_INJECTED_HEADER_KEYS_EXTENSION + + captured_headers: list[httpx.Headers] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + captured_headers.append(request.headers) + return httpx.Response(200) + + user_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + tool_a = MCPStreamableHTTPTool( + name="a", + url="http://example.com/mcp", + http_client=user_client, + header_provider=lambda _kw: {"Authorization": "Bearer token-a"}, + ) + tool_b = MCPStreamableHTTPTool( + name="b", + url="http://example.com/mcp", + http_client=user_client, + header_provider=lambda _kw: {"X-API-Key": "current"}, + ) + hook_started = asyncio.Event() + allow_hook = asyncio.Event() + + async def delayed_hook(_request: httpx.Request) -> None: + hook_started.set() + await allow_hook.wait() + + send_task: asyncio.Task[httpx.Response] | None = None + try: + with patch("agent_framework._mcp.streamable_http_client"): + tool_a.get_mcp_client() + tool_b.get_mcp_client() + user_client.event_hooks["request"].insert(1, delayed_hook) + + request = httpx.Request( + "POST", + "http://other.example/redirected", + headers={"X-API-Key": "previous"}, + extensions={ + _MCP_HEADER_OWNER_EXTENSION: tool_b._header_request_owner, + _MCP_INJECTED_HEADER_KEYS_EXTENSION: ("X-API-Key",), + }, + ) + send_task = asyncio.create_task(user_client.send(request)) + await hook_started.wait() + + await tool_a.close() + allow_hook.set() + await send_task + + assert len(captured_headers) == 1 + assert "X-API-Key" not in captured_headers[0] + finally: + allow_hook.set() + if send_task is not None: + await send_task + await user_client.aclose() + + +async def test_mcp_streamable_http_tool_keeps_header_hook_until_cancelled_close_finishes(): + """Caller cancellation must not remove the hook while lifecycle teardown continues.""" + import httpx + + user_client = httpx.AsyncClient() + tool = MCPStreamableHTTPTool( + name="test", + url="http://example.com/mcp", + http_client=user_client, + header_provider=lambda _kw: {"Authorization": "Bearer token"}, + ) + close_started = asyncio.Event() + allow_close = asyncio.Event() + # Recorded rather than asserted here: an assertion raised on the lifecycle owner task + # is swallowed by its error handling, so it would pass even when the hook is detached. + hook_attached_during_teardown: list[bool] = [] + + async def delayed_close() -> None: + close_started.set() + await allow_close.wait() + hook_attached_during_teardown.append(tool._inject_headers_hook in user_client.event_hooks["request"]) + + try: + with patch("agent_framework._mcp.streamable_http_client"): + tool.get_mcp_client() + assert tool._inject_headers_hook in user_client.event_hooks["request"] + + with patch.object(MCPTool, "_close_on_owner", side_effect=delayed_close): + close_task = asyncio.create_task(tool.close()) + await close_started.wait() + owner_task = tool._lifecycle_owner_task + assert owner_task is not None + + close_task.cancel() + with pytest.raises(asyncio.CancelledError): + await close_task + assert tool._inject_headers_hook in user_client.event_hooks["request"] + + allow_close.set() + await owner_task + + assert hook_attached_during_teardown == [True] + assert tool._inject_headers_hook not in user_client.event_hooks["request"] + finally: + allow_close.set() + owner_task = tool._lifecycle_owner_task + if owner_task is not None: + await owner_task + await user_client.aclose() + + +async def test_mcp_header_scoped_client_delegates_unwrapped_attributes(): + """The transport wrapper must stay a drop-in for the caller's httpx client.""" + import httpx + + from agent_framework._mcp import _MCPHeaderScopedClient + + user_client = httpx.AsyncClient(headers={"X-Base": "static"}, follow_redirects=True) + try: + wrapper = _MCPHeaderScopedClient(user_client, object()) + assert wrapper.headers["X-Base"] == "static" + assert wrapper.follow_redirects is True + assert wrapper.build_request("POST", "http://example.com/mcp").method == "POST" + finally: + await user_client.aclose() + + async def test_mcp_streamable_http_tool_header_provider_via_invoke_with_context(): """Test that header_provider receives kwargs via FunctionTool.invoke with FunctionInvocationContext. From 9c611f296f9d337c9e95312febe6d2572c06ae95 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:19:14 +0000 Subject: [PATCH 52/85] Chore(deps): Bump softprops/action-gh-release from 3.0.2 to 3.0.3 (#8100) Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 3.0.2 to 3.0.3. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/3d0d9888cb7fd7b750713d6e236d1fcb99157228...efb35369e0ad2afab669f228072c1b0d510eae64) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-version: 3.0.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/python-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index 3819e0c9e8b..c800e1787da 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -125,7 +125,7 @@ jobs: uv run poe --directory "packages/$PACKAGE" build fi - name: Release - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: files: | python/dist/* From e38939b8c0694a55d4e56592e0db7f03d0cf3d81 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:21:30 +0000 Subject: [PATCH 53/85] Build(deps): Bump azure/login from 2.3.0 to 3.0.2 (#8111) Bumps [azure/login](https://github.com/azure/login) from 2.3.0 to 3.0.2. - [Release notes](https://github.com/azure/login/releases) - [Commits](https://github.com/azure/login/compare/a457da9ea143d694b1b9c7c869ebb04ebe844ef5...7ddb5af1ef8758cf1353cf3b42f940aee27ba21c) --- updated-dependencies: - dependency-name: azure/login dependency-version: 3.0.2 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/dotnet-build-and-test.yml | 4 ++-- .github/workflows/dotnet-integration-tests.yml | 2 +- .github/workflows/dotnet-verify-samples.yml | 2 +- .github/workflows/issue-triage.yml | 2 +- .github/workflows/python-integration-tests.yml | 6 +++--- .github/workflows/python-merge-tests.yml | 6 +++--- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/dotnet-build-and-test.yml b/.github/workflows/dotnet-build-and-test.yml index 5a8e21a68ad..4c90100af1c 100644 --- a/.github/workflows/dotnet-build-and-test.yml +++ b/.github/workflows/dotnet-build-and-test.yml @@ -418,7 +418,7 @@ jobs: - name: Azure CLI Login if: github.event_name != 'pull_request' && matrix.integration-tests - uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0 + uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} @@ -560,7 +560,7 @@ jobs: run: dotnet build dotnet/tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj -c "$configuration" --warnaserror - name: Azure CLI Login - uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0 + uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} diff --git a/.github/workflows/dotnet-integration-tests.yml b/.github/workflows/dotnet-integration-tests.yml index 939033a3517..3bbdfb2f3c4 100644 --- a/.github/workflows/dotnet-integration-tests.yml +++ b/.github/workflows/dotnet-integration-tests.yml @@ -77,7 +77,7 @@ jobs: done - name: Azure CLI Login - uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0 + uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} diff --git a/.github/workflows/dotnet-verify-samples.yml b/.github/workflows/dotnet-verify-samples.yml index e101939d41b..e24c4233275 100644 --- a/.github/workflows/dotnet-verify-samples.yml +++ b/.github/workflows/dotnet-verify-samples.yml @@ -58,7 +58,7 @@ jobs: - name: Azure CLI Login if: github.event_name != 'pull_request' - uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0 + uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml index 0e8a70fe401..d6636a78034 100644 --- a/.github/workflows/issue-triage.yml +++ b/.github/workflows/issue-triage.yml @@ -200,7 +200,7 @@ jobs: run: uv sync --frozen - name: Azure CLI Login - uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0 + uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} diff --git a/.github/workflows/python-integration-tests.yml b/.github/workflows/python-integration-tests.yml index 257a523d6da..d802b9c9524 100644 --- a/.github/workflows/python-integration-tests.yml +++ b/.github/workflows/python-integration-tests.yml @@ -138,7 +138,7 @@ jobs: python-version: ${{ env.UV_PYTHON }} os: ${{ runner.os }} - name: Azure CLI Login - uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0 + uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} @@ -308,7 +308,7 @@ jobs: python-version: ${{ env.UV_PYTHON }} os: ${{ runner.os }} - name: Azure CLI Login - uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0 + uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} @@ -358,7 +358,7 @@ jobs: python-version: ${{ env.UV_PYTHON }} os: ${{ runner.os }} - name: Azure CLI Login - uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0 + uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml index 4d227f4346b..70278b4b80f 100644 --- a/.github/workflows/python-merge-tests.yml +++ b/.github/workflows/python-merge-tests.yml @@ -224,7 +224,7 @@ jobs: os: ${{ runner.os }} - name: Azure CLI Login if: github.event_name != 'pull_request' - uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0 + uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} @@ -422,7 +422,7 @@ jobs: os: ${{ runner.os }} - name: Azure CLI Login if: github.event_name != 'pull_request' - uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0 + uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} @@ -483,7 +483,7 @@ jobs: os: ${{ runner.os }} - name: Azure CLI Login if: github.event_name != 'pull_request' - uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0 + uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} From 3f6752078bce780eeb75476ba6df0bfde7a8d17a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:22:02 +0000 Subject: [PATCH 54/85] Build(deps): Bump azure/login in /.github/actions/github-app-token (#8112) Bumps [azure/login](https://github.com/azure/login) from 2.3.0 to 3.0.2. - [Release notes](https://github.com/azure/login/releases) - [Commits](https://github.com/azure/login/compare/a457da9ea143d694b1b9c7c869ebb04ebe844ef5...7ddb5af1ef8758cf1353cf3b42f940aee27ba21c) --- updated-dependencies: - dependency-name: azure/login dependency-version: 3.0.2 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/github-app-token/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/github-app-token/action.yml b/.github/actions/github-app-token/action.yml index d5afe8b32d6..519d9b44b5e 100644 --- a/.github/actions/github-app-token/action.yml +++ b/.github/actions/github-app-token/action.yml @@ -59,7 +59,7 @@ runs: id: azure-login if: ${{ (inputs.mode || 'app-with-fallback') != 'pat' }} continue-on-error: true - uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0 + uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 with: client-id: ${{ inputs.azure-client-id }} tenant-id: ${{ inputs.azure-tenant-id }} From 8e803b97a241e208fea9a78eeb6bf54a83fcef11 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:22:35 +0000 Subject: [PATCH 55/85] Build(deps): Bump azure/login (#8113) Bumps [azure/login](https://github.com/azure/login) from 2.3.0 to 3.0.2. - [Release notes](https://github.com/azure/login/releases) - [Commits](https://github.com/azure/login/compare/a457da9ea143d694b1b9c7c869ebb04ebe844ef5...7ddb5af1ef8758cf1353cf3b42f940aee27ba21c) --- updated-dependencies: - dependency-name: azure/login dependency-version: 3.0.2 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/sample-validation-setup/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/sample-validation-setup/action.yml b/.github/actions/sample-validation-setup/action.yml index 5887f16ee25..020823448f0 100644 --- a/.github/actions/sample-validation-setup/action.yml +++ b/.github/actions/sample-validation-setup/action.yml @@ -59,7 +59,7 @@ runs: sample-playbooks-${{ github.job }}- - name: Azure CLI Login - uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0 + uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 with: client-id: ${{ inputs.azure-client-id }} tenant-id: ${{ inputs.azure-tenant-id }} From f2e46c34a388400b6ba573faf8d0a6be1591e554 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:09:57 +0000 Subject: [PATCH 56/85] .NET: Improve Hosted Agent LRA Resilient Recovery Sample (#8082) * Current workload * Updates * Improving the service demo * .NET: Simplify resilient workflow sample and demonstrate service idempotency * Clarify local demonstration scope of resilience samples --- dotnet/Directory.Packages.props | 1 + .../ClawAgent.Hosted/.gitignore | 3 +- .../.env.example | 7 +- .../HostedWorkflowResilientLongRunning.csproj | 4 + .../Program.cs | 140 ++- .../README.md | 138 +-- .../azure.yaml | 3 +- .../IdempotentServiceClient.cs | 61 ++ .../Using-E2E-Resilience/IdempotentService.cs | 140 +++ .../responses/Using-E2E-Resilience/Program.cs | 987 ++---------------- .../responses/Using-E2E-Resilience/README.md | 192 ++-- .../Using-E2E-Resilience/ServerManager.cs | 380 +++++++ .../Using-E2E-Resilience/ServerProcess.cs | 65 ++ .../Using-E2E-Resilience.csproj | 6 +- .../responses/Using-Samples/README.md | 4 +- .../AgentFrameworkResponseHandler.cs | 140 ++- .../OutputConverter.cs | 65 +- .../OutputConverterWorkflowTests.cs | 27 + 18 files changed, 1172 insertions(+), 1191 deletions(-) create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted_Shared_Contributor_Setup/IdempotentServiceClient.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-E2E-Resilience/IdempotentService.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-E2E-Resilience/ServerManager.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-E2E-Resilience/ServerProcess.cs diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index c9346739261..94e08a91c3b 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -39,6 +39,7 @@ + diff --git a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/.gitignore b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/.gitignore index 5fe72b02045..bf405d0a839 100644 --- a/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/.gitignore +++ b/dotnet/samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step04_ProductionReady/ClawAgent.Hosted/.gitignore @@ -1,2 +1,3 @@ .azure -azure.yaml \ No newline at end of file +azure.yaml +Properties/launchSettings.json \ No newline at end of file diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Resilient-Long-Running/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Resilient-Long-Running/.env.example index 1104c2d65ba..e415ccc83a3 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Resilient-Long-Running/.env.example +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Resilient-Long-Running/.env.example @@ -1,5 +1,4 @@ -# Optional local countdown delay -COUNTDOWN_DELAY_SECONDS=1 - -# Local development only +# Local development only ASPNETCORE_URLS=http://+:8088 +IDEMPOTENT_SERVICE_ENDPOINT=http://localhost:8089/ +IDEMPOTENT_OPERATION_SCOPE=local-countdown diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Resilient-Long-Running/HostedWorkflowResilientLongRunning.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Resilient-Long-Running/HostedWorkflowResilientLongRunning.csproj index 57d2acbe972..cd1bfe36bff 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Resilient-Long-Running/HostedWorkflowResilientLongRunning.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Resilient-Long-Running/HostedWorkflowResilientLongRunning.csproj @@ -19,6 +19,10 @@ true + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Resilient-Long-Running/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Resilient-Long-Running/Program.cs index 0928eac8cb6..fea5c679ec1 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Resilient-Long-Running/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Resilient-Long-Running/Program.cs @@ -2,11 +2,11 @@ // Sample: a long-running countdown workflow hosted as a resilient background response. // Each completed superstep is paired with an AgentServer response checkpoint so a restarted -// process resumes with ordered output and without losing or duplicating countdown items. +// process resumes without losing confirmed output. An interrupted in-flight step can run again. using System.Globalization; -using System.Text.RegularExpressions; using DotNetEnv; +using Hosted_Shared_Contributor_Setup; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Foundry.Hosting; using Microsoft.Agents.AI.Workflows; @@ -16,20 +16,16 @@ var agentName = System.Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-workflow-resilient-long-running"; -var delaySeconds = int.TryParse( - System.Environment.GetEnvironmentVariable("COUNTDOWN_DELAY_SECONDS"), - NumberStyles.None, - CultureInfo.InvariantCulture, - out int configuredDelaySeconds) - ? configuredDelaySeconds - : 1; -if (delaySeconds < 0) -{ - throw new InvalidOperationException("COUNTDOWN_DELAY_SECONDS must be zero or greater."); -} +var idempotentServiceEndpoint = System.Environment.GetEnvironmentVariable("IDEMPOTENT_SERVICE_ENDPOINT") + ?? throw new InvalidOperationException("IDEMPOTENT_SERVICE_ENDPOINT is not set."); +var operationScope = System.Environment.GetEnvironmentVariable("IDEMPOTENT_OPERATION_SCOPE") + ?? throw new InvalidOperationException("IDEMPOTENT_OPERATION_SCOPE is not set."); + +using var idempotentServiceHttpClient = new HttpClient { BaseAddress = new Uri(idempotentServiceEndpoint) }; +var idempotentService = new IdempotentServiceClient(idempotentServiceHttpClient); var start = new CountdownStartExecutor(); -var countdown = new CountdownExecutor(TimeSpan.FromSeconds(delaySeconds)); +var countdown = new CountdownExecutor(idempotentService, operationScope); var complete = new CountdownCompleteExecutor(); Workflow workflow = new WorkflowBuilder(start) @@ -46,60 +42,98 @@ includeWorkflowOutputsInResponse: true); var builder = WebApplication.CreateBuilder(args); -builder.Services.AddFoundryResponses( - agent, - configure: options => options.ResilientBackground = true); +builder.Services.AddFoundryResponses(agent, configure: options => options.ResilientBackground = true); var app = builder.Build(); app.MapFoundryResponses(); +Task? requestedShutdown = null; if (app.Environment.IsDevelopment()) { app.MapFoundryResponses("openai/v1"); } +// This configuration is for local development demonstration purposes only. +// When hosted in Foundry the lifetime of the agent process is managed and shutdowns are handled gracefully. +if (app.Environment.IsDevelopment() + && string.Equals( + System.Environment.GetEnvironmentVariable("ENABLE_E2E_SHUTDOWN_ENDPOINT"), + "true", + StringComparison.OrdinalIgnoreCase)) +{ + app.MapPost("/shutdown", (IEnumerable hostedServices) => + { + // The E2E closes its client stream first, then signals the resilient task service before + // stopping HTTP. This reproduces the hosted-service shutdown path used by AgentServer tests. + IHostedService taskDurabilityService = hostedServices.SingleOrDefault( + service => string.Equals( + service.GetType().FullName, + "Azure.AI.AgentServer.Core.Tasks.Engine.TaskDurabilityService", + StringComparison.Ordinal)) + ?? throw new InvalidOperationException("The AgentServer resilient task service is not registered."); +#pragma warning disable CA2025 // Awaited after app.RunAsync before top-level disposable resources are disposed. + requestedShutdown ??= StopServerAsync(app, taskDurabilityService); +#pragma warning restore CA2025 + return Results.Accepted(); + }); +} + Console.WriteLine($"Process ID: {System.Environment.ProcessId}"); -app.Run(); +await app.RunAsync(); +if (requestedShutdown is not null) +{ + await requestedShutdown; +} +static async Task StopServerAsync(WebApplication app, IHostedService taskDurabilityService) +{ + await Task.Delay(TimeSpan.FromMilliseconds(100)); + await taskDurabilityService.StopAsync(CancellationToken.None); + await app.StopAsync(); +} + +/// +/// Starts the countdown ten above the numeric input so the E2E can interrupt it after some progress. +/// [SendsMessage(typeof(int))] -[YieldsOutput(typeof(string))] -internal sealed partial class CountdownStartExecutor() : ChatProtocolExecutor( - "start", - new ChatProtocolExecutorOptions { AutoSendTurnToken = false }) +internal sealed class CountdownStartExecutor() : ChatProtocolExecutor( + "start", new ChatProtocolExecutorOptions { AutoSendTurnToken = false }) { protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) => base.ConfigureProtocol(protocolBuilder).SendsMessage(); - protected override async ValueTask TakeTurnAsync( + protected override ValueTask TakeTurnAsync( List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default) { - string input = string.Join( - System.Environment.NewLine, - messages.Select(message => message.Text).Where(text => !string.IsNullOrWhiteSpace(text))); - Match match = PositiveIntegerRegex().Match(input); - if (!match.Success - || !int.TryParse(match.Value, NumberStyles.None, CultureInfo.InvariantCulture, out int target) - || target <= 0) - { - await context.YieldOutputAsync( - "The message must contain a positive integer counter target.", - cancellationToken); - return; - } + // The first workflow turn contains one message; add 10 to its countdown start value. + var maxNumberOfMessages = 10 + int.Parse(messages.Single().Text, CultureInfo.InvariantCulture); - await context.SendMessageAsync(target, cancellationToken: cancellationToken); + return context.SendMessageAsync(maxNumberOfMessages, cancellationToken: cancellationToken); } - - [GeneratedRegex(@"(? +/// Calls the idempotent service for each count, yields its result, and schedules the next count. +/// +/// +/// +/// For demonstration purposes only. The workflow and its backing service should not be used as-is in production. +/// +/// +/// A service call can finish before the workflow and response checkpoints are confirmed. +/// If the process stops during that interval, recovery can call the service again for the same count. +/// Reusing the scope and operation ID lets the service return the stored result without repeating its effect. +/// Checkpoint recovery and stream replay do not undo effects already performed by downstream services. +/// +/// [SendsMessage(typeof(int))] [SendsMessage(typeof(string))] [YieldsOutput(typeof(string))] -internal sealed class CountdownExecutor(TimeSpan delay) : Executor("countdown") +internal sealed class CountdownExecutor( + IdempotentServiceClient idempotentService, + string operationScope) : Executor("countdown") { public override async ValueTask HandleAsync( int message, @@ -108,30 +142,22 @@ public override async ValueTask HandleAsync( { if (message <= 0) { - await context.SendMessageAsync( - "Countdown complete.", - targetId: "complete", - cancellationToken: cancellationToken); + await context.SendMessageAsync(string.Empty, targetId: "complete", cancellationToken: cancellationToken); return; } - await Task.Delay(delay, cancellationToken); - await context.YieldOutputAsync( - message.ToString(CultureInfo.InvariantCulture), - cancellationToken); - await context.SendMessageAsync( - message - 1, - targetId: "countdown", - cancellationToken: cancellationToken); + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + string result = await idempotentService.ExecuteOperationAsync(operationScope, message, cancellationToken); + await context.YieldOutputAsync(result, cancellationToken); + await context.SendMessageAsync(message - 1, targetId: this.Id, cancellationToken: cancellationToken); } } -[YieldsOutput(typeof(string))] -internal sealed class CountdownCompleteExecutor() : Executor("complete") +internal sealed class CountdownCompleteExecutor() : Executor("complete") { - public override ValueTask HandleAsync( + public override ValueTask HandleAsync( string message, IWorkflowContext context, CancellationToken cancellationToken = default) => - context.YieldOutputAsync(message, cancellationToken); + ValueTask.FromResult(message); } diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Resilient-Long-Running/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Resilient-Long-Running/README.md index 3b2b20498b1..af42d0aeb30 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Resilient-Long-Running/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Resilient-Long-Running/README.md @@ -1,40 +1,79 @@ # Hosted-Workflow-Resilient-Long-Running A deterministic countdown workflow that demonstrates resilient background execution. Each number is -one workflow output item. If the process stops, AgentServer restores the last response snapshot and -the workflow resumes from the exact workflow checkpoint ID recorded in that snapshot. +one workflow output item. The workflow calls a separate idempotent HTTP service before emitting each +number. If the process stops, AgentServer supplies its saved response and MAF resumes the workflow +from the checkpoint referenced by that response. -For an input such as `Count down from 6`, the final message outputs are: +> **Local demonstration only.** This sample was prepared for +> [`Using-E2E-Resilience`](../Using-E2E-Resilience/) to demonstrate workflow recovery after a crash or +> graceful shutdown. The countdown only makes step recovery visible, and the SQLite service simulates +> an operation to illustrate how repeated calls avoid duplicate records. Neither represents a real +> business scenario. This workflow, its HTTP client, and the service are not intended for production +> use or deployment to Foundry. + +The input must be a single message containing integer text. The start executor adds ten so the E2E +has some progress to interrupt. With input `10`, a normal run emits: ```text -6 -5 -4 -3 -2 +20 +19 +... +11 +10 +... 1 -Countdown complete. ``` -The exact list makes recovery errors visible. A missing item means response state advanced beyond -workflow state. A repeated item means the workflow resumed before the response snapshot boundary. +At zero, the workflow terminates without a visible completion message. A recovered stream can +include repeated text; this is separate from whether the service created duplicate operation records. ## Workflow | Executor | Behavior | | --- | --- | -| `start` | Reads the first positive integer from the request. | -| `countdown` | Waits, yields the current number, decrements it, and sends it back to itself. | -| `complete` | Yields `Countdown complete.` after the counter reaches zero. | +| `start` | Parses the single text input as an integer, adds ten, and sends it to `countdown`. | +| `countdown` | Waits one second, calls the idempotent service, yields the returned number, and sends the decremented number to itself. | +| `complete` | Receives an empty string when the count reaches zero and ends the workflow. | All executor IDs and the workflow agent ID are stable so a replacement process reconstructs the same workflow topology. +## Idempotent operations + +The countdown calls `IdempotentServiceClient` from `Hosted_Shared_Contributor_Setup`. The +[`Using-E2E-Resilience`](../Using-E2E-Resilience/) executable hosts the backing Kestrel service in a +separate process when started with `--idempotent-service`. Only that service accesses SQLite. + +The service uses `(scope, operation_id)` as the primary key. The count is the operation ID. The first +call creates a row containing its result: + +```text +Operation Crash/10 executed. +``` + +If recovery executes the same countdown step again, the insert leaves that row unchanged and the +service reads and returns its stored result: + +```text +Duplicate operation Crash/10 ignored. +``` + +Configure the service with `IDEMPOTENT_SERVICE_ENDPOINT` and identify the logical operation group +with `IDEMPOTENT_OPERATION_SCOPE`. Keep the scope unchanged across recovery attempts. Use a different +scope for an unrelated run, otherwise the same counts intentionally reuse the previous results. + +The service can complete an operation before the workflow's progress is confirmed. A crash or +shutdown during that interval can cause the step to be invoked again. The stable key protects the +database operation, not the execution of the workflow step or the text displayed by a client. +For a real email, payment, or API write, the downstream service must enforce equivalent idempotency. +Simply placing that external call beside a SQLite insert would not make the two actions atomic. + ## Recovery boundary -At every completed workflow superstep, Foundry Hosting: +After the workflow finishes a batch of work and creates its execution checkpoint, MAF Hosting: -1. Closes the response output item produced by that superstep. +1. Closes any remaining response output for that work. 2. Saves the matching AgentSession. 3. Writes the workflow checkpoint ID to AgentServer internal response metadata as `_last_checkpoint_id`. @@ -43,7 +82,14 @@ At every completed workflow superstep, Foundry Hosting: 5. Yields `ResponseEventStream.Checkpoint()`. On recovery, the handler reads `_last_checkpoint_id` from `PersistedResponse` and selects that exact -workflow checkpoint before execution continues. +workflow checkpoint before execution continues. `PersistedResponse` is the saved response supplied +by AgentServer, not the client's accumulated text. + +Workflow checkpoints, AgentSession storage, response storage, and the replay log are not one +transaction. Output can be published before its matching checkpoint is confirmed. Recovery can +therefore rerun unconfirmed work. A later `response.in_progress` event carries replacement response +state for clients that reconstruct the current result; it does not reverse an external service call. +See [streaming with reconnect](https://learn.microsoft.com/azure/foundry/agents/how-to/stream-with-reconnect). ## Local development @@ -53,57 +99,29 @@ The easiest local demonstration is the automated E2E console: dotnet run --project dotnet\samples\04-hosting\FoundryHostedAgents\responses\Using-E2E-Resilience ``` -It starts this server, prints countdown outputs, force-kills the process, restarts it, prints replay -and recovery outputs, and validates the final sequence. - -To run only the server, copy `.env.example` to `.env`, then run: - -```powershell -dotnet run --tl:off -``` - -Set `COUNTDOWN_DELAY_SECONDS=0` to make a normal run complete immediately. - -## Deploy from source - -Create an empty working directory outside the repository: - -```powershell -$work = Join-Path $env:TEMP "hosted-workflow-resilient-long-running-work" -New-Item -ItemType Directory -Path $work -Force | Out-Null -Set-Location $work - -$sample = "/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Resilient-Long-Running/azure.yaml" -azd auth login -azd ai agent init -m $sample -``` - -### Contributors testing framework changes - -Skip this section unless the current framework changes have not been released. Pack the repository -source into the scaffolded upload before provisioning: - -```powershell -/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/Add-LocalFrameworkFeed.ps1 ` - -Path ./hosted-workflow-resilient-long-running -``` +It runs both an abrupt process crash and a host shutdown after a countdown operation, starts a +replacement process for each scenario, displays replayed output, and queries the idempotent service +for its completed operation count. The current input is `10`, so 20 stored operations are expected. +The E2E reports the count rather than asserting it. The shutdown path exercises `IsShutdownRequested` +and recovery deferral. -Then deploy: +To run the components manually, start the service from the repository root in a separate terminal: ```powershell -Set-Location hosted-workflow-resilient-long-running -azd provision -azd deploy +$env:ASPNETCORE_URLS = "http://localhost:8089" +$env:IDEMPOTENT_SERVICE_DATABASE_PATH = Join-Path $env:TEMP "countdown-idempotency.db" +dotnet run --project dotnet\samples\04-hosting\FoundryHostedAgents\responses\Using-E2E-Resilience -- --idempotent-service ``` -Grant the hosted agent identity `Foundry User` on the Foundry project so it can write workflow -checkpoints and AgentSession state. +In this hosted sample's directory, copy `.env.example` to `.env`, configure the service endpoint and +scope, and run `dotnet run`. Send integer text such as `"10"` to the Responses endpoint. Closing the +HTTP stream of an accepted background response does not cancel its server-side execution. ## Automated coverage `ResilientTwoLifetimeIntegrationTests.StoppedHost_RecoversWorkflowWithCompleteOrderedOutputAsync` -starts the Responses host twice over shared durable state. It interrupts the first host while the -counter is processing `3`, then verifies that the recovered response contains exactly: +uses its own test workflow, not these sample executors. It starts the Responses host twice over +shared durable state, interrupts the first host while its counter is processing `3`, and verifies: ```text 6, 5, 4, 3, 2, 1, Countdown complete. @@ -111,7 +129,7 @@ counter is processing `3`, then verifies that the recovered response contains ex ## Related samples -- [Using-E2E-Resilience](../Using-E2E-Resilience/README.md): automated local crash-recovery console. +- [Using-E2E-Resilience](../Using-E2E-Resilience/README.md): automated crash and shutdown recovery console. - [Hosted-Workflow-Resilient](../Hosted-Workflow-Resilient/README.md): resilient model-backed translation workflow. - [Hosted-Workflow-Simple](../Hosted-Workflow-Simple/README.md): workflow hosting without resilient background execution. - [Hosted-Steering](../Hosted-Steering/README.md): mid-turn steering. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Resilient-Long-Running/azure.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Resilient-Long-Running/azure.yaml index 55281bc1c08..83b59499147 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Resilient-Long-Running/azure.yaml +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Resilient-Long-Running/azure.yaml @@ -16,7 +16,8 @@ services: runtime: dotnet_10 env: ASPNETCORE_URLS: http://+:8088 - COUNTDOWN_DELAY_SECONDS: "1" + IDEMPOTENT_SERVICE_ENDPOINT: ${IDEMPOTENT_SERVICE_ENDPOINT} + IDEMPOTENT_OPERATION_SCOPE: ${IDEMPOTENT_OPERATION_SCOPE} container: resources: cpu: "0.5" diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted_Shared_Contributor_Setup/IdempotentServiceClient.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted_Shared_Contributor_Setup/IdempotentServiceClient.cs new file mode 100644 index 00000000000..db8cd970e31 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted_Shared_Contributor_Setup/IdempotentServiceClient.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net.Http.Json; +using System.Text.Json; + +namespace Hosted_Shared_Contributor_Setup; + +/// +/// Calls the idempotent operation service used by the resilience samples. +/// +/// +/// +/// For demonstration purposes only. This sample client and its backing service should not be used as-is in production. +/// +/// +/// Recovery may execute a workflow step again if its checkpoint was not saved before a crash. +/// Services called by that step must handle repeated requests without repeating unintended downstream effects. +/// This sample reuses the same scope and operation ID so the service can return the previously stored result. +/// Idempotency is enforced by the service, not by this client. +/// +/// +public sealed class IdempotentServiceClient(HttpClient httpClient) +{ + /// + /// Executes an operation or returns its previously stored result. + /// + /// The operation scope. + /// The operation identifier within the scope. + /// The cancellation token. + /// The operation result. + public async Task ExecuteOperationAsync( + string scope, + int operationId, + CancellationToken cancellationToken = default) + { + using HttpResponseMessage response = await httpClient.PostAsync( + new Uri($"operations/{Uri.EscapeDataString(scope)}/{operationId}", UriKind.Relative), + content: null, + cancellationToken); + response.EnsureSuccessStatusCode(); + await using Stream content = await response.Content.ReadAsStreamAsync(cancellationToken); + using JsonDocument document = await JsonDocument.ParseAsync(content, cancellationToken: cancellationToken); + return document.RootElement.GetProperty("result").GetString() + ?? throw new InvalidOperationException("The idempotent service returned an empty result."); + } + + /// + /// Gets the number of completed operations in a scope. + /// + /// The operation scope. + /// The cancellation token. + /// The number of completed operations. + public async Task GetOperationCountAsync(string scope, CancellationToken cancellationToken = default) + { + int? count = await httpClient.GetFromJsonAsync( + new Uri($"operations/{Uri.EscapeDataString(scope)}/count", UriKind.Relative), + cancellationToken); + return count + ?? throw new InvalidOperationException("The idempotent service returned an empty operation count."); + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-E2E-Resilience/IdempotentService.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-E2E-Resilience/IdempotentService.cs new file mode 100644 index 00000000000..d58032f5fa6 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-E2E-Resilience/IdempotentService.cs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Globalization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.DependencyInjection; + +/// +/// Hosts the SQLite-backed operation service used by the resilience demonstration. +/// +/// +/// +/// For demonstration purposes only. This service and its client should not be used as-is in production. +/// The service runs in a separate process so its operation records survive the hosted workflow process being replaced. +/// +/// +/// Recovery may execute an unconfirmed workflow step again. The same scope and operation ID return the stored result +/// instead of adding another row. Reusing the result prevents a repeated service effect, not repeated stream text. +/// The database insert is the simulated effect; this does not make an external email or payment call transactional. +/// +/// +internal static class IdempotentService +{ + /// + /// Runs the service when this executable is started with --idempotent-service. + /// + /// Host arguments after removing --idempotent-service. + public static async Task RunAsync(string[] args) + { + string databasePath = System.Environment.GetEnvironmentVariable("IDEMPOTENT_SERVICE_DATABASE_PATH") + ?? Path.Combine(AppContext.BaseDirectory, "operations.db"); + var store = new SqliteOperationStore(databasePath); + + var builder = WebApplication.CreateBuilder(args); + builder.Services.AddSingleton(store); + + await using var app = builder.Build(); + app.MapGet("/readiness", () => Results.Ok()); + + app.MapPost("/operations/{scope}/{operationId:int}", + async ( + string scope, + int operationId, + SqliteOperationStore operationStore, + CancellationToken cancellationToken) => + { + OperationResult operation = await operationStore.ExecuteAsync(scope, operationId, cancellationToken); + return Results.Ok(operation); + }); + + app.MapGet("/operations/{scope}/count", + async (string scope, SqliteOperationStore operationStore, CancellationToken cancellationToken) + => Results.Ok(await operationStore.GetCountAsync(scope, cancellationToken))); + + await app.RunAsync(); + } + + private sealed record OperationResult(string Result, bool Created); + + private sealed class SqliteOperationStore + { + private readonly string _connectionString; + + public SqliteOperationStore(string databasePath) + { + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(databasePath))!); + this._connectionString = new SqliteConnectionStringBuilder + { + DataSource = databasePath, + }.ToString(); + + using var connection = new SqliteConnection(this._connectionString); + connection.Open(); + using SqliteCommand command = connection.CreateCommand(); + command.CommandText = + """ + CREATE TABLE IF NOT EXISTS operations ( + scope TEXT NOT NULL, + operation_id INTEGER NOT NULL, + result TEXT NOT NULL, + PRIMARY KEY (scope, operation_id) + ); + """; + command.ExecuteNonQuery(); + } + + public async Task ExecuteAsync(string scope, int operationId, CancellationToken cancellationToken) + { + string result = operationId.ToString(CultureInfo.InvariantCulture); + await using var connection = new SqliteConnection(this._connectionString); + await connection.OpenAsync(cancellationToken); + + await using SqliteCommand insert = connection.CreateCommand(); + insert.CommandText = + """ + INSERT OR IGNORE INTO operations (scope, operation_id, result) + VALUES ($scope, $operationId, $result); + """; + insert.Parameters.AddWithValue("$scope", scope); + insert.Parameters.AddWithValue("$operationId", operationId); + insert.Parameters.AddWithValue("$result", result); + bool created = await insert.ExecuteNonQueryAsync(cancellationToken) == 1; + if (created) + { + Console.WriteLine($"Operation {scope}/{operationId} executed."); + return new(result, Created: true); + } + + await using SqliteCommand select = connection.CreateCommand(); + select.CommandText = + """ + SELECT result + FROM operations + WHERE scope = $scope AND operation_id = $operationId; + """; + select.Parameters.AddWithValue("$scope", scope); + select.Parameters.AddWithValue("$operationId", operationId); + string storedResult = (string?)await select.ExecuteScalarAsync(cancellationToken) + ?? throw new InvalidOperationException($"Operation {scope}/{operationId} exists without a stored result."); + Console.WriteLine($"Duplicate operation {scope}/{operationId} ignored."); + return new(storedResult, Created: false); + } + + public async Task GetCountAsync(string scope, CancellationToken cancellationToken) + { + await using var connection = new SqliteConnection(this._connectionString); + await connection.OpenAsync(cancellationToken); + await using SqliteCommand command = connection.CreateCommand(); + command.CommandText = + """ + SELECT COUNT(*) + FROM operations + WHERE scope = $scope; + """; + command.Parameters.AddWithValue("$scope", scope); + return Convert.ToInt32(await command.ExecuteScalarAsync(cancellationToken), CultureInfo.InvariantCulture); + } + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-E2E-Resilience/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-E2E-Resilience/Program.cs index 65136cce510..88aec8ecd26 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-E2E-Resilience/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-E2E-Resilience/Program.cs @@ -1,221 +1,31 @@ // Copyright (c) Microsoft. All rights reserved. -using System.ClientModel; -using System.ClientModel.Primitives; -using System.Diagnostics; -using System.Globalization; -using System.Net; -using System.Net.Sockets; -using System.Text; -using System.Text.Json; -using Azure.AI.Projects; +// Demonstrates workflow recovery and service-side idempotency across process interruptions. +// Sample only, not a production implementation. Repeated stream text is displayed, not used to execute operations. + using Hosted_Shared_Contributor_Setup; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; -using OpenAI.Responses; +using static ServerManager; -const string AgentName = "hosted-workflow-resilient-long-running"; -VerificationOptions options = VerificationOptions.Parse(args); -string repositoryRoot = FindRepositoryRoot(); -string serverProject = Path.Combine( - repositoryRoot, - "dotnet", - "samples", - "04-hosting", - "FoundryHostedAgents", - "responses", - "Hosted-Workflow-Resilient-Long-Running", - "HostedWorkflowResilientLongRunning.csproj"); -string workingRoot = Path.Combine( - Path.GetTempPath(), - $"maf-resilient-workflow-{Guid.NewGuid():N}"); -string serverOutput = Path.Combine(workingRoot, "server"); -string serverAssembly = Path.Combine( - serverOutput, - "HostedWorkflowResilientLongRunning.dll"); -string stateRoot = Path.Combine(workingRoot, "state"); -string logPath = Path.Combine( - Path.GetTempPath(), - $"maf-resilient-workflow-{Guid.NewGuid():N}.log"); -int port = GetAvailablePort(); -var baseAddress = new Uri($"http://127.0.0.1:{port}"); -bool succeeded = false; +if (args is ["--idempotent-service", .. var serviceArgs]) +{ + await IdempotentService.RunAsync(serviceArgs); + return; +} -Directory.CreateDirectory(workingRoot); +using var cancellationSource = new CancellationTokenSource(TimeSpan.FromMinutes(6)); +string interruptNumberMessage = "10"; -var cancellationSource = new CancellationTokenSource(TimeSpan.FromMinutes(3)); -var client = new HttpClient -{ - BaseAddress = baseAddress, - Timeout = Timeout.InfiniteTimeSpan, -}; -await using var logWriter = new StreamWriter(logPath, append: false, new UTF8Encoding(false)) -{ - AutoFlush = true, -}; -ServerProcess? server = null; -Task? createStream = null; -AgentStreamObserver? streamObserver = null; -LocalAgentClient? localAgentClient = null; -CancellationTokenSource? initialStreamCancellation = null; try { - PrintHeader(options, stateRoot, logPath); - - Console.WriteLine("Preparing isolated Debug server binaries..."); - await BuildServerAsync( - serverProject, - serverOutput, - logWriter, - cancellationSource.Token); - Console.WriteLine(" server build complete"); - Console.WriteLine(); - - Console.WriteLine("[1/7] Starting the first server process..."); - Console.WriteLine($" endpoint: {baseAddress}"); - server = StartServer( - serverAssembly, - stateRoot, - port, - options.DelaySeconds, - logWriter); - Console.WriteLine($" process tree root: {server.Id}"); - await WaitForReadinessAsync(client, cancellationSource.Token); - Console.WriteLine(" server ready"); - Console.WriteLine(); - - localAgentClient = CreateClientAgent(baseAddress, AgentName); - AIAgent agent = localAgentClient.Agent; - AgentSession session = await agent.CreateSessionAsync(cancellationSource.Token); - AgentRunOptions runOptions = new() { AllowBackgroundResponses = true }; - - Console.WriteLine("[2/7] Starting the background countdown..."); - streamObserver = new AgentStreamObserver(options.CrashAfterCount); - initialStreamCancellation = - CancellationTokenSource.CreateLinkedTokenSource(cancellationSource.Token); -#pragma warning disable CA2025 // The stream must run concurrently until the server is killed; finally awaits it before disposing resources. - createStream = WatchInitialAgentStreamAsync( - agent, - session, - runOptions, - options.Target, - streamObserver, - initialStreamCancellation.Token); -#pragma warning restore CA2025 - - string responseId = await WaitForResponseIdAsync( - streamObserver, - createStream, - cancellationSource.Token); - Console.WriteLine($" response id: {responseId}"); - Console.WriteLine(); - - Console.WriteLine( - $"[3/7] Waiting for {options.CrashAfterCount} countdown items and their response checkpoint..."); - await streamObserver.CrashPointReached.Task.WaitAsync(cancellationSource.Token); - await WaitForPersistedResponseCheckpointAsync( - stateRoot, - responseId, - streamObserver.CompletedTexts, - cancellationSource.Token); - Console.WriteLine(" checkpoint persisted"); - Console.WriteLine(); - - Console.WriteLine("[4/7] Force-killing the first server process..."); - initialStreamCancellation.Cancel(); - await IgnoreExpectedDisconnectAsync(createStream); - createStream = null; - initialStreamCancellation.Dispose(); - initialStreamCancellation = null; - await server.KillAsync(); - server = null; - DeleteStaleStreamLocks(stateRoot); - Console.WriteLine(" process terminated"); - Console.WriteLine(); - - Console.WriteLine("[5/7] Starting a replacement server over the same durable state..."); - server = StartServer( - serverAssembly, - stateRoot, - port, - options.DelaySeconds, - logWriter); - Console.WriteLine($" process tree root: {server.Id}"); - await WaitForReadinessAsync(client, cancellationSource.Token); - Console.WriteLine(" recovery scan completed"); - Console.WriteLine(); - Console.WriteLine("[6/7] Reconnecting with the sequence-aware continuation token..."); - streamObserver.BeginRecovery(); - runOptions.ContinuationToken = streamObserver.ContinuationToken - ?? throw new InvalidOperationException( - "The initial stream did not provide a continuation token."); - await WatchRecoveredAgentStreamAsync( - agent, - session, - runOptions, - streamObserver, - cancellationSource.Token); - - List actual = streamObserver.CompletedTexts; - List expected = - [ - .. Enumerable.Range(1, options.Target) - .Reverse() - .Select(value => value.ToString(CultureInfo.InvariantCulture)), - "Countdown complete.", - ]; - - if (!streamObserver.ResponseCompleted) - { - throw new InvalidOperationException( - "The recovered stream ended without response.completed."); - } - - if (!actual.SequenceEqual(expected)) - { - throw new InvalidOperationException( - "Recovered output did not match the expected countdown." + - $"{Environment.NewLine}Expected: {string.Join(", ", expected)}" + - $"{Environment.NewLine}Actual: {string.Join(", ", actual)}"); - } - - Console.WriteLine(); - Console.WriteLine("[7/7] Replaying from the start without a sequence cursor..."); - AgentRunOptions replayOptions = new() - { - AllowBackgroundResponses = true, - ContinuationToken = CreateReplayFromStartToken(responseId), - }; - var replayObserver = new AgentStreamObserver(int.MaxValue); - await WatchReplayedAgentStreamAsync( - agent, - session, - replayOptions, - replayObserver, - cancellationSource.Token); - if (!replayObserver.ResponseCompleted - || !replayObserver.CompletedTexts.SequenceEqual(expected)) - { - throw new InvalidOperationException( - "The cursor-free replay did not return the complete countdown."); - } - int retainedCountdownUpdates = - actual.Count(text => text != "Countdown complete."); - int replayedCountdownUpdates = - replayObserver.CompletedTexts.Count( - text => text != "Countdown complete."); - Console.WriteLine(); - Console.WriteLine( - $"Client retained countdown updates: {retainedCountdownUpdates}"); - Console.WriteLine( - $"Replay countdown updates: {replayedCountdownUpdates}"); + await RunScenarioAsync(interruptNumberMessage, InterruptionKind.Crash, cancellationSource.Token); + await RunScenarioAsync(interruptNumberMessage, InterruptionKind.Shutdown, cancellationSource.Token); Console.WriteLine(); Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine( - "PASS: crash recovery completed with ordered output and no missing or duplicated items."); + Console.WriteLine("PASS: both recovery paths completed with every countdown operation stored once in SQLite."); Console.ResetColor(); - succeeded = true; } catch (Exception exception) { @@ -223,749 +33,116 @@ await WatchReplayedAgentStreamAsync( Console.ForegroundColor = ConsoleColor.Red; Console.Error.WriteLine($"FAIL: {exception.Message}"); Console.ResetColor(); - Console.Error.WriteLine($"Server log: {logPath}"); System.Environment.ExitCode = 1; } -finally -{ - if (server is not null) - { - await server.KillAsync(); - } - - if (createStream is not null) - { - initialStreamCancellation?.Cancel(); - await IgnoreExpectedDisconnectAsync(createStream); - } - - initialStreamCancellation?.Dispose(); - client.Dispose(); - localAgentClient?.Dispose(); - cancellationSource.Dispose(); - - if (succeeded) - { - TryDeleteDirectory(workingRoot); - } - else - { - Console.Error.WriteLine($"E2E working directory retained at: {workingRoot}"); - } -} -static void PrintHeader( - VerificationOptions options, - string stateRoot, - string logPath) -{ - Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine("============================================================"); - Console.WriteLine("Resilient long-running workflow E2E demonstration"); - Console.WriteLine("============================================================"); - Console.ResetColor(); - Console.WriteLine($"Countdown target: {options.Target}"); - Console.WriteLine($"Crash after: {options.CrashAfterCount} message items"); - Console.WriteLine($"Step delay: {options.DelaySeconds} second(s)"); - Console.WriteLine($"Durable state: {stateRoot}"); - Console.WriteLine($"Server log: {logPath}"); - Console.WriteLine(); -} - -static ServerProcess StartServer( - string serverAssembly, - string stateRoot, - int port, - int delaySeconds, - TextWriter logWriter) -{ - var startInfo = new ProcessStartInfo - { - FileName = "dotnet", - WorkingDirectory = Path.GetDirectoryName(serverAssembly)!, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }; - startInfo.ArgumentList.Add("exec"); - startInfo.ArgumentList.Add(serverAssembly); - startInfo.Environment["AGENTSERVER_STATE_ROOT"] = stateRoot; - startInfo.Environment["FOUNDRY_AGENT_SESSION_ID"] = "using-e2e-resilience"; - startInfo.Environment["AGENT_NAME"] = AgentName; - startInfo.Environment["ASPNETCORE_URLS"] = $"http://127.0.0.1:{port}"; - startInfo.Environment["ASPNETCORE_ENVIRONMENT"] = "Development"; - startInfo.Environment["COUNTDOWN_DELAY_SECONDS"] = - delaySeconds.ToString(CultureInfo.InvariantCulture); - startInfo.Environment["DOTNET_NOLOGO"] = "true"; - startInfo.Environment.Remove("FOUNDRY_HOSTING_ENVIRONMENT"); - - return ServerProcess.Start(startInfo, logWriter); -} - -static LocalAgentClient CreateClientAgent(Uri baseAddress, string agentName) -{ - Uri httpsProjectEndpoint = new UriBuilder(baseAddress) - { - Scheme = Uri.UriSchemeHttps, - Port = baseAddress.Port, - }.Uri; - - var transportClient = new HttpClient( - new LocalHttpSchemeRewriteHandler(baseAddress)); - var clientOptions = new AIProjectClientOptions - { - Transport = new HttpClientPipelineTransport(transportClient), - }; - - AIAgent agent = new AIProjectClient( - httpsProjectEndpoint, - new LocalDevelopmentTokenCredential(), - clientOptions) - .AsAIAgent( - model: agentName, - instructions: "Invoke the local hosted countdown workflow."); - return new LocalAgentClient(agent, transportClient); -} - -static ResponseContinuationToken CreateReplayFromStartToken( - string responseId) -{ - ResponseContinuationToken innerToken = - ResponseContinuationToken.FromBytes( - JsonSerializer.SerializeToUtf8Bytes( - new { responseId })); - string serializedInnerToken = JsonSerializer.Serialize( - innerToken, - AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo( - typeof(ResponseContinuationToken))); - byte[] bytes = JsonSerializer.SerializeToUtf8Bytes( - new - { - type = "chatClientAgentContinuationToken", - innerToken = serializedInnerToken, - }); - return ResponseContinuationToken.FromBytes(bytes); -} - -static async Task BuildServerAsync( - string serverProject, - string serverOutput, - TextWriter logWriter, +static async Task RunScenarioAsync( + string interruptNumberMessage, + InterruptionKind interruptionKind, CancellationToken cancellationToken) { - var startInfo = new ProcessStartInfo - { - FileName = "dotnet", - WorkingDirectory = Path.GetDirectoryName(serverProject)!, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }; - startInfo.ArgumentList.Add("build"); - startInfo.ArgumentList.Add(serverProject); - startInfo.ArgumentList.Add("--configuration"); - startInfo.ArgumentList.Add("Debug"); - startInfo.ArgumentList.Add("--output"); - startInfo.ArgumentList.Add(serverOutput); - startInfo.ArgumentList.Add("--tl:off"); - startInfo.Environment["DOTNET_NOLOGO"] = "true"; + await using var serverManager = new ServerManager(interruptionKind); - using Process process = Process.Start(startInfo) - ?? throw new InvalidOperationException("Could not start the server build."); - TextWriter synchronizedLogWriter = TextWriter.Synchronized(logWriter); - process.OutputDataReceived += (_, eventArgs) => - { - if (eventArgs.Data is not null) - { - synchronizedLogWriter.WriteLine($"[build stdout] {eventArgs.Data}"); - } - }; - process.ErrorDataReceived += (_, eventArgs) => - { - if (eventArgs.Data is not null) - { - synchronizedLogWriter.WriteLine($"[build stderr] {eventArgs.Data}"); - } - }; - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); + PrintHeader(interruptionKind, serverManager); - await process.WaitForExitAsync(cancellationToken); - process.WaitForExit(); - if (process.ExitCode != 0) - { - throw new InvalidOperationException( - $"Server build failed with exit code {process.ExitCode}."); - } -} + Console.WriteLine($"[{interruptionKind} 1/6] Building the hosted server..."); + await serverManager.BuildServerAsync(cancellationToken); -static async Task WaitForReadinessAsync( - HttpClient client, - CancellationToken cancellationToken) -{ - var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(30); - while (DateTimeOffset.UtcNow < deadline) - { - try - { - using var requestCancellation = - CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - requestCancellation.CancelAfter(TimeSpan.FromSeconds(2)); - using HttpResponseMessage response = await client.GetAsync( - new Uri("readiness", UriKind.Relative), - requestCancellation.Token); - if (response.StatusCode == HttpStatusCode.OK) - { - return; - } - } - catch (Exception exception) - when (exception is HttpRequestException or TaskCanceledException) - { - } + Console.WriteLine($"[{interruptionKind} 2/6] Starting the idempotent service and hosted server..."); + Console.WriteLine($" Idempotent service Process ID: {await serverManager.StartIdempotentServiceAsync(cancellationToken)}"); + Console.WriteLine($" Hosted server Process ID: {await serverManager.StartHostedAgentServerAsync(cancellationToken)}"); - await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken); - } - - throw new TimeoutException("Server did not become ready within 30 seconds."); -} - -static async Task WatchInitialAgentStreamAsync( - AIAgent agent, - AgentSession session, - AgentRunOptions options, - int target, - AgentStreamObserver observer, - CancellationToken cancellationToken) -{ - await foreach (AgentResponseUpdate update in agent.RunStreamingAsync( - $"Count down from {target}", - session, - options, - cancellationToken)) - { - observer.ObserveInitial(update); - } -} + AIAgent agent = serverManager.GetAIAgent(); + AgentSession session = await agent.CreateSessionAsync(cancellationToken); + IdempotentServiceClient idempotentService = new(new HttpClient { BaseAddress = serverManager.IdempotentServiceBaseAddress }); -static async Task WatchRecoveredAgentStreamAsync( - AIAgent agent, - AgentSession session, - AgentRunOptions options, - AgentStreamObserver observer, - CancellationToken cancellationToken) -{ - await foreach (AgentResponseUpdate update in agent.RunStreamingAsync( - session, - options, - cancellationToken)) - { - observer.ObserveRecovered(update); - } -} + Console.WriteLine($"[{interruptionKind} 3/6] Starting the background response..."); -static async Task WatchReplayedAgentStreamAsync( - AIAgent agent, - AgentSession session, - AgentRunOptions options, - AgentStreamObserver observer, - CancellationToken cancellationToken) -{ - await foreach (AgentResponseUpdate update in agent.RunStreamingAsync( - session, - options, - cancellationToken)) + ResponseContinuationToken responseToken; + await using (var response = agent.RunStreamingAsync( + interruptNumberMessage, session, new AgentRunOptions { AllowBackgroundResponses = true }, cancellationToken) + .GetAsyncEnumerator(cancellationToken)) { - observer.ObserveReplayed(update); - } -} - -static async Task WaitForResponseIdAsync( - AgentStreamObserver observer, - Task createStream, - CancellationToken cancellationToken) -{ - Task completed = await Task.WhenAny( - observer.ResponseId.Task, - createStream, - Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken)); - if (completed == createStream) - { - await createStream; - throw new InvalidOperationException( - "The initial stream ended before returning a response ID."); - } - - cancellationToken.ThrowIfCancellationRequested(); - return await observer.ResponseId.Task; -} - -static async Task WaitForPersistedResponseCheckpointAsync( - string stateRoot, - string responseId, - IReadOnlyList expectedPrefix, - CancellationToken cancellationToken) -{ - string path = Path.Combine( - stateRoot, - "responses", - "envelopes", - $"{responseId}.json"); - var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(15); - while (DateTimeOffset.UtcNow < deadline) - { - try - { - using FileStream file = new( - path, - FileMode.Open, - FileAccess.Read, - FileShare.ReadWrite | FileShare.Delete); - using JsonDocument document = await JsonDocument.ParseAsync( - file, - cancellationToken: cancellationToken); - JsonElement response = - document.RootElement.GetProperty("envelope"); - List persistedTexts = GetPersistedMessageTexts(response); - bool hasCheckpointMetadata = - response.TryGetProperty("metadata", out JsonElement metadata) - && metadata.TryGetProperty("_internal_metadata", out JsonElement internalMetadata) - && !string.IsNullOrWhiteSpace(internalMetadata.GetString()); - if (hasCheckpointMetadata - && persistedTexts.Count >= expectedPrefix.Count - && persistedTexts - .Take(expectedPrefix.Count) - .SequenceEqual(expectedPrefix)) - { - return; - } - } - catch (Exception exception) - when (exception is IOException - or JsonException - or KeyNotFoundException) - { - } - - await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); - } - - throw new TimeoutException( - "The response checkpoint was not persisted within 15 seconds."); -} - -static List GetPersistedMessageTexts(JsonElement response) -{ - List texts = []; - foreach (JsonElement item in response.GetProperty("output").EnumerateArray()) - { - if (item.GetProperty("type").GetString() != "message") + if (!await response.MoveNextAsync()) { - continue; + throw new InvalidOperationException("The background response ended before it was accepted."); } - foreach (JsonElement content in item.GetProperty("content").EnumerateArray()) - { - if (content.GetProperty("type").GetString() == "output_text") - { - texts.Add(content.GetProperty("text").GetString() ?? string.Empty); - } - } + responseToken = response.Current.ContinuationToken + ?? throw new InvalidOperationException("The accepted response did not provide a continuation token."); } - return texts; -} + Console.WriteLine($"[{interruptionKind} 4/6] Waiting for operation {interruptNumberMessage}..."); -static async Task IgnoreExpectedDisconnectAsync(Task streamTask) -{ - try - { - await streamTask; - } - catch (Exception exception) - when (IsExpectedDisconnect(exception)) + var followOptions = new AgentRunOptions { - } - - static bool IsExpectedDisconnect(Exception exception) - { - if (exception is AggregateException aggregate) - { - return aggregate - .Flatten() - .InnerExceptions - .All(IsExpectedDisconnect); - } - - return exception is ClientResultException - or HttpRequestException - or IOException - or OperationCanceledException; - } -} - -static void DeleteStaleStreamLocks(string stateRoot) -{ - string streamsPath = Path.Combine(stateRoot, "streams"); - if (!Directory.Exists(streamsPath)) - { - return; - } - - foreach (string lockPath in Directory.EnumerateFiles( - streamsPath, - "*.jsonl.lock", - SearchOption.TopDirectoryOnly)) - { - for (int attempt = 1; attempt <= 10; attempt++) - { - try - { - File.Delete(lockPath); - break; - } - catch (UnauthorizedAccessException) when (attempt < 10) - { - Thread.Sleep(TimeSpan.FromMilliseconds(250)); - } - catch (IOException) when (attempt < 10) - { - Thread.Sleep(TimeSpan.FromMilliseconds(250)); - } - } - } -} - -static int GetAvailablePort() -{ - var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - int port = ((IPEndPoint)listener.LocalEndpoint).Port; - listener.Stop(); - return port; -} - -static string FindRepositoryRoot() -{ - foreach (string start in new[] { Environment.CurrentDirectory, AppContext.BaseDirectory }) - { - DirectoryInfo? directory = new(start); - while (directory is not null) - { - if (File.Exists(Path.Combine( - directory.FullName, - "dotnet", - "agent-framework-dotnet.slnx"))) - { - return directory.FullName; - } - - directory = directory.Parent; - } - } - - throw new InvalidOperationException( - "Could not find the Agent Framework repository root."); -} + AllowBackgroundResponses = true, + ContinuationToken = responseToken, + }; -static void TryDeleteDirectory(string path) -{ try { - Directory.Delete(path, recursive: true); - } - catch (IOException) - { - } - catch (UnauthorizedAccessException) - { - } -} - -internal sealed class AgentStreamObserver(int crashAfterCount) -{ - private readonly Dictionary _messageBuffers = - new(StringComparer.Ordinal); - private readonly HashSet _completedMessageIds = - new(StringComparer.Ordinal); - private List? _preCrashTexts; - private bool? _recoveryIncludesSnapshot; - private int _recoverySnapshotIndex; - private int _messageCount; - - public TaskCompletionSource ResponseId { get; } = - new(TaskCreationOptions.RunContinuationsAsynchronously); - - public TaskCompletionSource CrashPointReached { get; } = - new(TaskCreationOptions.RunContinuationsAsynchronously); - - public List CompletedTexts { get; } = []; - - public ResponseContinuationToken? ContinuationToken { get; private set; } - - public bool ResponseCompleted { get; private set; } - - public void BeginRecovery() - { - this._preCrashTexts = [.. this.CompletedTexts]; - this._recoveryIncludesSnapshot = null; - this._recoverySnapshotIndex = 0; - } - - public void ObserveInitial(AgentResponseUpdate update) => - this.Observe(update, "before", trackCheckpoint: true); - - public void ObserveRecovered(AgentResponseUpdate update) => - this.Observe(update, "recovered", trackCheckpoint: false); - - public void ObserveReplayed(AgentResponseUpdate update) => - this.Observe(update, "replayed", trackCheckpoint: false); - - private void Observe( - AgentResponseUpdate update, - string phase, - bool trackCheckpoint) - { - object? rawRepresentation = - update.RawRepresentation is ChatResponseUpdate chatResponseUpdate - ? chatResponseUpdate.RawRepresentation - : update.RawRepresentation; - - if (update.ContinuationToken is { } continuationToken) - { - this.ContinuationToken = continuationToken; - } - - if (!string.IsNullOrWhiteSpace(update.ResponseId)) - { - this.ResponseId.TrySetResult(update.ResponseId); - } - - if (!string.IsNullOrWhiteSpace(update.MessageId) - && !string.IsNullOrEmpty(update.Text)) - { - if (!this._messageBuffers.TryGetValue( - update.MessageId, - out StringBuilder? buffer)) - { - buffer = new StringBuilder(); - this._messageBuffers[update.MessageId] = buffer; - } - - buffer.Append(update.Text); - } - - if (rawRepresentation is StreamingResponseOutputItemDoneUpdate - { - Item: MessageResponseItem message - } - && this._completedMessageIds.Add(message.Id)) + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(session, followOptions, cancellationToken)) { - string text = this._messageBuffers.TryGetValue( - message.Id, - out StringBuilder? buffer) - ? buffer.ToString() - : string.Empty; - if (phase != "before" && text.Length == 0) - { - return; - } - - if (phase == "recovered" - && this.TryHandleRecoverySnapshot(text)) + if (!string.IsNullOrEmpty(update.Text)) { - return; + Console.WriteLine($" {update.Text}"); } - this.CompletedTexts.Add(text); - WriteOutput(phase, text); - - if (trackCheckpoint && ++this._messageCount >= crashAfterCount) + if (update.Text.Contains(interruptNumberMessage, StringComparison.OrdinalIgnoreCase)) { - this.CrashPointReached.TrySetResult(); + if (interruptionKind == InterruptionKind.Crash) + { + Console.WriteLine(" Killing / crashing the server ..."); + await serverManager.CrashServerAsync(); + serverManager.DeleteStaleStreamLocks(); + } + else + { + Console.WriteLine(" Shutting down the server ..."); + await serverManager.RequestShutdownAsync(cancellationToken); + await serverManager.WaitForServerExitAsync(cancellationToken); + } } } - - if (rawRepresentation is StreamingResponseCompletedUpdate) - { - this.ResponseCompleted = true; - } } - - private bool TryHandleRecoverySnapshot(string text) + catch { - if (this._preCrashTexts is not { Count: > 0 } preCrashTexts) - { - return false; - } - - this._recoveryIncludesSnapshot ??= - string.Equals(text, preCrashTexts[0], StringComparison.Ordinal); - if (this._recoveryIncludesSnapshot is not true) - { - return false; - } - - if (this._recoverySnapshotIndex >= preCrashTexts.Count) - { - return false; - } - - if (!string.Equals( - text, - preCrashTexts[this._recoverySnapshotIndex], - StringComparison.Ordinal)) - { - throw new InvalidOperationException( - "The response snapshot returned during reconnection did not match the pre-crash output."); - } - - this._recoverySnapshotIndex++; - WriteOutput("restored", text); - return true; - } - - private static void WriteOutput(string phase, string text) - { - Console.ForegroundColor = phase == "recovered" - ? ConsoleColor.Green - : ConsoleColor.DarkGray; - Console.WriteLine($" {phase,-9} > {text}"); - Console.ResetColor(); - } -} - -internal sealed class ServerProcess -{ - private readonly Process _process; - private readonly Task _outputPump; - private readonly Task _errorPump; - - private ServerProcess(Process process, TextWriter logWriter) - { - this._process = process; - this._outputPump = PumpAsync(process.StandardOutput, logWriter, "stdout"); - this._errorPump = PumpAsync(process.StandardError, logWriter, "stderr"); + Console.WriteLine(" The connection was interrupted."); } - public int Id => this._process.Id; + Console.WriteLine($"[{interruptionKind} 5/6] Starting the replacement server..."); + Console.WriteLine($" Process ID: {await serverManager.StartHostedAgentServerAsync(cancellationToken)}"); - public static ServerProcess Start( - ProcessStartInfo startInfo, - TextWriter logWriter) + Console.WriteLine($"[{interruptionKind} 6/6] Reading the recovered response..."); + var recoveryOptions = new AgentRunOptions { - Process process = Process.Start(startInfo) - ?? throw new InvalidOperationException("Could not start the server process."); - return new ServerProcess(process, TextWriter.Synchronized(logWriter)); - } - - public async Task KillAsync() - { - if (!this._process.HasExited) - { - this._process.Kill(entireProcessTree: true); - } - - await this._process.WaitForExitAsync(); - await Task.WhenAll(this._outputPump, this._errorPump) - .WaitAsync(TimeSpan.FromSeconds(5)); - this._process.Dispose(); - } + AllowBackgroundResponses = true, + ContinuationToken = responseToken, + }; - private static async Task PumpAsync( - StreamReader reader, - TextWriter writer, - string source) + List textUpdates = []; + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(session, recoveryOptions, cancellationToken)) { - while (await reader.ReadLineAsync() is { } line) + if (!string.IsNullOrEmpty(update.Text)) { - await writer.WriteLineAsync($"[{source}] {line}"); + Console.WriteLine($" {update.Text}{(textUpdates.Contains(update.Text) ? " > Repeated due to abrupt crash and recovery replay (idempotency matters here)" : "")}"); + textUpdates.Add(update.Text); } } -} -internal sealed class LocalAgentClient( - AIAgent agent, - HttpClient transportClient) : IDisposable -{ - public AIAgent Agent { get; } = agent; - - public void Dispose() => transportClient.Dispose(); + int operationCount = await idempotentService.GetOperationCountAsync(serverManager.OperationScope, cancellationToken); + Console.WriteLine($" Idempotent service contains {operationCount} completed operations."); + serverManager.MarkSucceeded(); } -internal sealed record VerificationOptions( - int Target, - int CrashAfterCount, - int DelaySeconds) +static void PrintHeader(InterruptionKind interruptionKind, ServerManager harness) { - public static VerificationOptions Parse(string[] args) - { - int target = 20; - int? crashAfterCount = null; - int delaySeconds = 1; - - for (int index = 0; index < args.Length; index++) - { - string argument = args[index]; - switch (argument) - { - case "--target": - target = ReadInteger(args, ref index, argument); - break; - case "--crash-after-count": - crashAfterCount = ReadInteger(args, ref index, argument); - break; - case "--delay-seconds": - delaySeconds = ReadInteger(args, ref index, argument); - break; - default: - throw new ArgumentException($"Unknown argument '{argument}'."); - } - } - - int resolvedCrashAfterCount = crashAfterCount ?? Math.Max(1, target / 2); - if (target < 2) - { - throw new ArgumentOutOfRangeException( - nameof(args), - "Target must be at least 2."); - } - - if (resolvedCrashAfterCount < 1 || resolvedCrashAfterCount >= target) - { - throw new ArgumentOutOfRangeException( - nameof(args), - "Crash count must be greater than zero and less than the target."); - } - - if (delaySeconds < 0) - { - throw new ArgumentOutOfRangeException( - nameof(args), - "Delay seconds must be zero or greater."); - } - - return new(target, resolvedCrashAfterCount, delaySeconds); - } - - private static int ReadInteger( - string[] args, - ref int index, - string argument) - { - if (++index >= args.Length - || !int.TryParse( - args[index], - NumberStyles.None, - CultureInfo.InvariantCulture, - out int value)) - { - throw new ArgumentException( - $"Argument '{argument}' requires an integer value."); - } - - return value; - } + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine("============================================================"); + Console.WriteLine($"Running {(interruptionKind == InterruptionKind.Crash ? "Abrupt process crash" : "Host shutdown")} scenario ... "); + Console.WriteLine("============================================================"); + Console.ResetColor(); + Console.WriteLine(); } diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-E2E-Resilience/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-E2E-Resilience/README.md index 1741878c4db..69c44bb459a 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-E2E-Resilience/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-E2E-Resilience/README.md @@ -1,103 +1,123 @@ -# Using-E2E-Resilience - -A self-contained local E2E demonstration for -[`Hosted-Workflow-Resilient-Long-Running`](../Hosted-Workflow-Resilient-Long-Running/). -It owns both server process lifetimes, consumes their response stream, and prints every countdown -output in one console. - -The E2E creates a MAF client agent through `AIProjectClient.AsAIAgent(model, instructions)`. It -enables `AgentRunOptions.AllowBackgroundResponses`, consumes `AgentResponseUpdate` values, saves the -latest non-null `ResponseContinuationToken`, and supplies that token after the replacement server -starts. It does not implement the Responses HTTP or SSE protocol itself. - -The demonstration uses one MAF client agent and one agent session for three calls: - -1. Starts the hosted workflow server as a child process. -2. Creates a stored background streaming response through the MAF agent. -3. Prints countdown messages as MAF streaming updates arrive. -4. Waits until the matching workflow and response checkpoint is durable. -5. Force-kills the server process tree. -6. Starts a replacement server over the same AgentServer state. -7. The second call reconnects with the sequence-aware continuation token and prints only newly - recovered messages. -8. The third call uses the same agent and session with the same response ID but no sequence cursor, - replaying the entire stream from the start. -9. The E2E verifies that the client accumulator and cursor-free replay contain the same complete - countdown. +# Using-E2E-Resilience -## Run +This local E2E runs +[`Hosted-Workflow-Resilient-Long-Running`](../Hosted-Workflow-Resilient-Long-Running/) +through two recovery scenarios: -Run from the repository root: +1. `Crash`, which terminates the hosted workflow process abruptly. +2. `Shutdown`, which follows the graceful shutdown path and defers the response for recovery. -```powershell -dotnet run --project dotnet\samples\04-hosting\FoundryHostedAgents\responses\Using-E2E-Resilience -``` +> **Local demonstration only.** The E2E, countdown workflow, HTTP client, and SQLite service exist only +> to demonstrate recovery after a crash or graceful shutdown. The countdown makes step recovery +> visible, and the database insert is a simulated operation; no real email, payment, or other external +> action is performed. This is not a real business scenario or a template for production use or +> deployment to Foundry. + +## Three processes + +The E2E uses three processes while each scenario is running: + +| Process | Responsibility | +| --- | --- | +| `Using-E2E-Resilience` | Drives the scenario and consumes the recovered stream. | +| `Hosted-Workflow-Resilient-Long-Running` | Runs the resilient countdown workflow. | +| `Using-E2E-Resilience --idempotent-service` | Runs `IdempotentService` on Kestrel and stores operations in SQLite. | + +The idempotent service is a class inside this sample, not a separate project or demo. The E2E +launches another instance of its own executable with `--idempotent-service`. That process runs only +the service, not the Crash and Shutdown scenarios, and remains running while the hosted workflow +process is replaced. -The E2E program starts the first server, ends it abruptly, starts the replacement server with the -same durable state, and ends the replacement when the verification completes. A separately running -local server may remain open: the E2E uses a random port, isolated Debug binaries, and an isolated -AgentServer state directory. +Both the hosted workflow and the E2E use `IdempotentServiceClient` from +`Hosted_Shared_Contributor_Setup`: -No Azure project, model deployment, credentials, or second terminal is required. -The E2E builds the server in Debug into an isolated temporary directory, so it does not reuse or -overwrite the binaries of a separately running local server. +1. The hosted workflow posts operations to the idempotent service. +2. The idempotent service stores them in SQLite. +3. The E2E queries the same service for the completed operation count. -`AIProjectClient` requires an HTTPS endpoint before its bearer-token policy will run. The shared -`LocalHttpSchemeRewriteHandler` presents HTTPS to that pipeline, then routes the request to the -random loopback HTTP port at transport time. The handler rejects non-loopback targets. +The workflow calls the service for every countdown value. The service stores each operation once +using `(scope, operation_id)` as the SQLite primary key. If recovery repeats a workflow step, the +service returns the existing result instead of creating another row. -Example: +## Current flow + +`Program.cs` sets `interruptNumberMessage` to `"10"`. The hosted workflow adds ten to that numeric +input, so the countdown starts at 20 and the E2E interrupts it when it receives `10`. + +1. Build the hosted server and start the idempotent service and hosted server on separate loopback ports. +2. Start a streaming background response. Read its first update and save the continuation token. +3. Dispose that initial stream enumerator. This closes the client stream, not the accepted background operation. +4. Open a second stream with the token and interrupt the hosted process at the selected count. +5. Start a replacement hosted process with the same workflow state, service endpoint, and operation scope. +6. Open a third stream using the original token, then query the service for the completed operation count. + +The recovered stream is consumed independently of the second request. The client displays all text +updates and labels repeated text within that third request. It does not execute operations or +remove duplicates. With the current input, the expected database count is 20. The E2E prints the +returned count; it does not assert the count or the exact stream sequence. + +## Why idempotency matters + +An operation can finish at the service before the workflow's progress is durably confirmed. If the +hosted process stops during that interval, recovery may execute the step and call the service again. +The service's primary key makes the repeated call return the saved result without adding another row: ```text -[1/7] Starting the first server process... -[2/7] Starting the background countdown... - before > 20 - before > 19 - before > 18 -... -[4/7] Force-killing the first server process... -[5/7] Starting a replacement server over the same durable state... -[6/7] Reconnecting to the response stream... - recovered > 10 - recovered > 9 -... - recovered > Countdown complete. - -[7/7] Replaying from the start without a sequence cursor... - replayed > 20 - replayed > 19 -... - replayed > Countdown complete. - -Client retained countdown updates: 20 -Replay countdown updates: 20 - -PASS: crash recovery completed with ordered output and no missing or duplicated items. +Operation Crash/10 executed. +Duplicate operation Crash/10 ignored. ``` -## Options +Those are service log messages. Repeated text in the client stream can come from replaying old events +or running an unconfirmed step again, so repeated text alone does not prove a second service effect. +The timing of interruption determines whether the step must run again. -```powershell -dotnet run --project dotnet\samples\04-hosting\FoundryHostedAgents\responses\Using-E2E-Resilience -- ` - --target 30 ` - --crash-after-count 12 ` - --delay-seconds 1 -``` +**Workflow recovery, stream replay, and service idempotency have different jobs.** Checkpoints restore +workflow progress. Replay lets a client receive stored events again. Idempotency protects the +service's operation from being applied twice. Neither replay nor a checkpoint undoes an email or +payment already performed. Real downstream services must enforce that protection themselves. -| Option | Default | Meaning | +## Internal service mode + +The E2E starts service mode automatically with a random loopback address in `ASPNETCORE_URLS` and an +isolated SQLite file in `IDEMPOTENT_SERVICE_DATABASE_PATH`. Both are preserved while the hosted +workflow restarts. At the end, the E2E stops the service and removes temporary state on success. + +Crash and Shutdown use separate temporary databases. Within each scenario, the service and its +database remain alive across both hosted process lifetimes. Failed scenarios retain their files and +print the paths for investigation; successful cleanup is best effort. + +| Method | Path | Purpose | | --- | --- | --- | -| `--target` | `20` | First countdown value. Must be at least 2. | -| `--crash-after-count` | Half the target | Number of completed countdown messages before the crash. | -| `--delay-seconds` | `1` | Delay between countdown steps. | +| `GET` | `/readiness` | Reports that the service is ready. | +| `POST` | `/operations/{scope}/{operationId}` | Creates an operation or returns its stored result. | +| `GET` | `/operations/{scope}/count` | Returns the number of completed operations in the scope. | + +## What normally triggers each path + +Foundry sends `SIGTERM` when it intentionally stops a hosted agent container and can provide a +graceful shutdown window. This can happen during managed lifecycle operations such as session +compute deprovisioning, scale-in, or redeployment. + +An abrupt crash provides no shutdown window. Typical examples include an application process crash, +a forced process termination, and an out-of-memory kill. + +The local Shutdown scenario requests `StopAsync()` on the AgentServer task service, then stops the +web host. It exercises the shutdown signal without sending an OS signal on Windows. It does not +guarantee that a partially completed workflow step will never run again. -Server output is redirected to a temporary log whose path is printed at startup. Each run uses a -random local port and an isolated AgentServer state directory. Successful runs delete their durable -state. Failed runs retain state and print its path for investigation. +See [the hosted agent runtime contract](https://learn.microsoft.com/azure/foundry/agents/concepts/hosted-agent-contract) +and [recovery guidance](https://learn.microsoft.com/azure/foundry/agents/how-to/recover-long-running-work). -The second call's continuation token resumes after the last update consumed before the crash. -Previously consumed countdown messages are retained in the client accumulator and are not streamed -again. Only work after the durable checkpoint appears as `recovered`. +## Run + +No Azure project, model deployment, credentials, or second terminal is required. The E2E builds the +hosted workflow server and starts both server processes automatically. + +From the repository root: + +```powershell +dotnet run --project dotnet\samples\04-hosting\FoundryHostedAgents\responses\Using-E2E-Resilience +``` -For the third call, the E2E derives another valid `ChatClientAgent` continuation token whose inner -Responses token contains the same response ID without a sequence number. That call prints every -persisted stream item as `replayed`. +To change the demonstration, edit `interruptNumberMessage` in `Program.cs`. `--idempotent-service` +selects the internal service process instead of running the E2E scenarios. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-E2E-Resilience/ServerManager.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-E2E-Resilience/ServerManager.cs new file mode 100644 index 00000000000..d4c3b8056e8 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-E2E-Resilience/ServerManager.cs @@ -0,0 +1,380 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ClientModel.Primitives; +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Text; +using Azure.AI.Projects; +using Hosted_Shared_Contributor_Setup; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +internal sealed class ServerManager(ServerManager.InterruptionKind interruptionKind) : IAsyncDisposable +{ + private const string AgentName = "hosted-workflow-resilient-long-running"; + private readonly string _repositoryRoot = FindRepositoryRoot(); + private readonly string _workingRoot = Path.Combine(Path.GetTempPath(), $"maf-resilience-{interruptionKind}-{Guid.NewGuid():N}"); + private readonly int _port = GetAvailablePort(); + private readonly int _servicePort = GetAvailablePort(); + private readonly StreamWriter _logWriter = new( + Path.Combine(Path.GetTempPath(), $"maf-resilience-{interruptionKind}-{Guid.NewGuid():N}.log"), + append: false, + new UTF8Encoding(false)) + { + AutoFlush = true, + }; + + private ServerProcess? _server; + private ServerProcess? _idempotentService; + private LocalAgentClient? _agentClient; + private bool _succeeded; + public string StateRoot => Path.Combine(this._workingRoot, "state"); + + public string LogPath => ((FileStream)this._logWriter.BaseStream).Name; + + public Uri HostedAgentBaseAddress => new($"http://127.0.0.1:{this._port}"); + + public Uri IdempotentServiceBaseAddress => new($"http://127.0.0.1:{this._servicePort}"); + + public string OperationScope => interruptionKind.ToString(); + + public HttpClient ControlClient { get; } = new() + { + Timeout = Timeout.InfiniteTimeSpan, + }; + + /// + /// Gets the instance pointing to the hosted server. + /// + /// The instance. + public AIAgent GetAIAgent() => (this._agentClient ??= CreateClientAgent(this.HostedAgentBaseAddress, AgentName)).Agent; + + public async Task BuildServerAsync(CancellationToken cancellationToken) + { + Directory.CreateDirectory(this._workingRoot); + string serverProject = Path.Combine( + this._repositoryRoot, + "dotnet", + "samples", + "04-hosting", + "FoundryHostedAgents", + "responses", + "Hosted-Workflow-Resilient-Long-Running", + "HostedWorkflowResilientLongRunning.csproj"); + await this.BuildProjectAsync(serverProject, Path.Combine(this._workingRoot, "server"), cancellationToken); + } + + public async Task StartIdempotentServiceAsync(CancellationToken cancellationToken) + { + if (this._idempotentService is not null) + { + throw new InvalidOperationException("The idempotent service is already running."); + } + + string serviceAssembly = Path.Combine(AppContext.BaseDirectory, "using-e2e-resilience.dll"); + var startInfo = new ProcessStartInfo + { + FileName = "dotnet", + WorkingDirectory = Path.GetDirectoryName(serviceAssembly)!, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + startInfo.ArgumentList.Add("exec"); + startInfo.ArgumentList.Add(serviceAssembly); + startInfo.ArgumentList.Add("--idempotent-service"); + startInfo.Environment["ASPNETCORE_URLS"] = $"http://127.0.0.1:{this._servicePort}"; + startInfo.Environment["IDEMPOTENT_SERVICE_DATABASE_PATH"] = Path.Combine(this._workingRoot, "idempotent-service.db"); + startInfo.Environment["DOTNET_NOLOGO"] = "true"; + + this._idempotentService = ServerProcess.Start(startInfo, this._logWriter); + await WaitForReadinessAsync(this.ControlClient, this.IdempotentServiceBaseAddress, cancellationToken); + return this._idempotentService.Id; + } + + public async Task StartHostedAgentServerAsync(CancellationToken cancellationToken) + { + if (this._server is not null) + { + throw new InvalidOperationException("The server is already running."); + } + + string serverAssembly = Path.Combine(this._workingRoot, "server", "HostedWorkflowResilientLongRunning.dll"); + var startInfo = new ProcessStartInfo + { + FileName = "dotnet", + WorkingDirectory = Path.GetDirectoryName(serverAssembly)!, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + startInfo.ArgumentList.Add("exec"); + startInfo.ArgumentList.Add(serverAssembly); + startInfo.Environment["AGENTSERVER_STATE_ROOT"] = this.StateRoot; + startInfo.Environment["FOUNDRY_AGENT_SESSION_ID"] = $"using-e2e-resilience-{interruptionKind}"; + startInfo.Environment["AGENT_NAME"] = AgentName; + startInfo.Environment["ASPNETCORE_URLS"] = $"http://127.0.0.1:{this._port}"; + startInfo.Environment["ASPNETCORE_ENVIRONMENT"] = "Development"; + startInfo.Environment["IDEMPOTENT_SERVICE_ENDPOINT"] = this.IdempotentServiceBaseAddress.AbsoluteUri; + startInfo.Environment["IDEMPOTENT_OPERATION_SCOPE"] = this.OperationScope; + startInfo.Environment["ENABLE_E2E_SHUTDOWN_ENDPOINT"] = + string.Equals(interruptionKind.ToString(), InterruptionKind.Shutdown.ToString(), StringComparison.OrdinalIgnoreCase) ? "true" : "false"; + startInfo.Environment["DOTNET_NOLOGO"] = "true"; + startInfo.Environment.Remove("FOUNDRY_HOSTING_ENVIRONMENT"); + + this._server = ServerProcess.Start(startInfo, this._logWriter); + await WaitForReadinessAsync(this.ControlClient, this.HostedAgentBaseAddress, cancellationToken); + return this._server.Id; + } + + public async Task CrashServerAsync() + { + ServerProcess server = this._server ?? throw new InvalidOperationException("The server is not running."); + await server.KillAsync(); + this._server = null; + } + + public async Task RequestShutdownAsync(CancellationToken cancellationToken) + { + using HttpResponseMessage response = await this.ControlClient.PostAsync( + new Uri(this.HostedAgentBaseAddress, "shutdown"), content: null, cancellationToken); + response.EnsureSuccessStatusCode(); + } + + public async Task WaitForServerExitAsync(CancellationToken cancellationToken) + { + ServerProcess server = this._server ?? throw new InvalidOperationException("The server is not running."); + await server.WaitForExitAsync(cancellationToken); + this._server = null; + } + + public void DeleteStaleStreamLocks() + { + string streamsPath = Path.Combine(this.StateRoot, "streams"); + if (!Directory.Exists(streamsPath)) + { + return; + } + + foreach (string lockPath in Directory.EnumerateFiles(streamsPath, "*.jsonl.lock", SearchOption.TopDirectoryOnly)) + { + for (int attempt = 1; attempt <= 10; attempt++) + { + try + { + File.Delete(lockPath); + break; + } + catch (UnauthorizedAccessException) when (attempt < 10) + { + Thread.Sleep(TimeSpan.FromMilliseconds(250)); + } + catch (IOException) when (attempt < 10) + { + Thread.Sleep(TimeSpan.FromMilliseconds(250)); + } + } + } + } + + public void MarkSucceeded() => this._succeeded = true; + + public async ValueTask DisposeAsync() + { + string logPath = this.LogPath; + if (this._server is not null) + { + await this._server.KillAsync(); + } + + if (this._idempotentService is not null) + { + await this._idempotentService.KillAsync(); + } + + this.ControlClient.Dispose(); + this._agentClient?.Dispose(); + await this._logWriter.DisposeAsync(); + + if (this._succeeded) + { + TryDeleteDirectory(this._workingRoot); + TryDeleteFile(logPath); + } + else + { + Console.Error.WriteLine($"E2E working directory retained at: {this._workingRoot}"); + Console.Error.WriteLine($"Server log: {logPath}"); + } + } + + private async Task BuildProjectAsync(string projectPath, string outputPath, CancellationToken cancellationToken) + { + var startInfo = new ProcessStartInfo + { + FileName = "dotnet", + WorkingDirectory = Path.GetDirectoryName(projectPath)!, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + startInfo.ArgumentList.Add("build"); + startInfo.ArgumentList.Add(projectPath); + startInfo.ArgumentList.Add("--configuration"); + startInfo.ArgumentList.Add("Debug"); + startInfo.ArgumentList.Add("--output"); + startInfo.ArgumentList.Add(outputPath); + startInfo.ArgumentList.Add("--tl:off"); + startInfo.Environment["DOTNET_NOLOGO"] = "true"; + + using Process process = Process.Start(startInfo) + ?? throw new InvalidOperationException($"Could not build '{projectPath}'."); + TextWriter synchronizedLogWriter = TextWriter.Synchronized(this._logWriter); + process.OutputDataReceived += (_, eventArgs) => + { + if (eventArgs.Data is not null) + { + synchronizedLogWriter.WriteLine($"[build stdout] {eventArgs.Data}"); + } + }; + process.ErrorDataReceived += (_, eventArgs) => + { + if (eventArgs.Data is not null) + { + synchronizedLogWriter.WriteLine($"[build stderr] {eventArgs.Data}"); + } + }; + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + await process.WaitForExitAsync(cancellationToken); + process.WaitForExit(); + if (process.ExitCode != 0) + { + throw new InvalidOperationException($"Build failed with exit code {process.ExitCode} for '{projectPath}'."); + } + } + + private static LocalAgentClient CreateClientAgent(Uri baseAddress, string agentName) + { + Uri httpsProjectEndpoint = new UriBuilder(baseAddress) + { + Scheme = Uri.UriSchemeHttps, + Port = baseAddress.Port, + }.Uri; + + var transportClient = new HttpClient(new LocalHttpSchemeRewriteHandler(baseAddress)); + var clientOptions = new AIProjectClientOptions + { + Transport = new HttpClientPipelineTransport(transportClient), + }; + + AIAgent agent = new AIProjectClient( + httpsProjectEndpoint, + new LocalDevelopmentTokenCredential(), + clientOptions) + .AsAIAgent( + model: agentName, + instructions: "Invoke the local hosted countdown workflow."); + return new LocalAgentClient(agent, transportClient); + } + + private static async Task WaitForReadinessAsync( + HttpClient client, Uri baseAddress, CancellationToken cancellationToken) + { + var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(30); + while (DateTimeOffset.UtcNow < deadline) + { + try + { + using var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + requestCancellation.CancelAfter(TimeSpan.FromSeconds(2)); + using HttpResponseMessage response = await client.GetAsync(new Uri(baseAddress, "readiness"), requestCancellation.Token); + if (response.StatusCode == HttpStatusCode.OK) + { + return; + } + } + catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException) + { + } + + await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken); + } + + throw new TimeoutException("Server did not become ready within 30 seconds."); + } + + private static int GetAvailablePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + private static string FindRepositoryRoot() + { + foreach (string start in new[] { Environment.CurrentDirectory, AppContext.BaseDirectory }) + { + DirectoryInfo? directory = new(start); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "dotnet", "agent-framework-dotnet.slnx"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + } + + throw new InvalidOperationException("Could not find the Agent Framework repository root."); + } + + private static void TryDeleteDirectory(string path) + { + try + { + Directory.Delete(path, recursive: true); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + + private static void TryDeleteFile(string path) + { + try + { + File.Delete(path); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + + internal enum InterruptionKind + { + Crash, + Shutdown, + } + + private sealed class LocalAgentClient(AIAgent agent, HttpClient transportClient) : IDisposable + { + public AIAgent Agent { get; } = agent; + + public void Dispose() => transportClient.Dispose(); + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-E2E-Resilience/ServerProcess.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-E2E-Resilience/ServerProcess.cs new file mode 100644 index 00000000000..951f6fe56a6 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-E2E-Resilience/ServerProcess.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; + +internal sealed class ServerProcess +{ + private readonly Process _process; + private readonly Task _outputPump; + private readonly Task _errorPump; + private bool _disposed; + + private ServerProcess(Process process, TextWriter logWriter) + { + this._process = process; + this._outputPump = PumpAsync(process.StandardOutput, logWriter, "stdout"); + this._errorPump = PumpAsync(process.StandardError, logWriter, "stderr"); + } + + public int Id => this._process.Id; + + public static ServerProcess Start(ProcessStartInfo startInfo, TextWriter logWriter) + { + Process process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Could not start the server process."); + return new ServerProcess(process, TextWriter.Synchronized(logWriter)); + } + + public async Task KillAsync() + { + if (!this._process.HasExited) + { + this._process.Kill(entireProcessTree: true); + } + + await this.CompleteAsync(); + } + + public async Task WaitForExitAsync(CancellationToken cancellationToken) + { + await this._process.WaitForExitAsync(cancellationToken); + await this.CompleteAsync(); + } + + private async Task CompleteAsync() + { + if (this._disposed) + { + return; + } + + await this._process.WaitForExitAsync(); + await Task.WhenAll(this._outputPump, this._errorPump) + .WaitAsync(TimeSpan.FromSeconds(5)); + this._process.Dispose(); + this._disposed = true; + } + + private static async Task PumpAsync(StreamReader reader, TextWriter writer, string source) + { + while (await reader.ReadLineAsync() is { } line) + { + await writer.WriteLineAsync($"[{source}] {line}"); + } + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-E2E-Resilience/Using-E2E-Resilience.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-E2E-Resilience/Using-E2E-Resilience.csproj index f450479e8e5..ebf75c79b5c 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-E2E-Resilience/Using-E2E-Resilience.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-E2E-Resilience/Using-E2E-Resilience.csproj @@ -14,10 +14,14 @@ - + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/README.md index 543132c0c5b..78d518670bb 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/README.md @@ -48,8 +48,8 @@ never hits the TLS check. | [`Hosted-Toolbox-AuthPaths-Client/`](./Hosted-Toolbox-AuthPaths-Client/) | Hosted toolbox agents | Handles OAuth consent, function-tool approvals, and native MCP approvals. Use it with `Hosted-Toolbox-AuthPaths` or `Hosted-ToolboxMcpSkills`. | | [`SessionFilesClient/`](./SessionFilesClient/) | [`Hosted-Files`](../Hosted-Files/) | Same shape as `SimpleAgent`, framed around the bundled-files demo. | -For a self-contained crash-recovery demonstration that starts, interrupts, and restarts its own -local server, see [`Using-E2E-Resilience`](../Using-E2E-Resilience/). +For a self-contained demonstration that covers both crash and shutdown recovery, see +[`Using-E2E-Resilience`](../Using-E2E-Resilience/). ## Configuration (common to all clients) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index 32248cf30b1..b9983ed6f05 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -212,11 +212,10 @@ public override async IAsyncEnumerable CreateAsync( ? new ResponseEventStream(context, persistedResponse) : new ResponseEventStream(context, request); - WorkflowSessionCheckpointRecovery? workflowCheckpointRecovery = - session?.GetService(); if (context.IsRecovery && sessionRestoredFromStore - && workflowCheckpointRecovery is not null) + && session?.GetService() + is { } workflowCheckpointRecovery) { string? checkpointId = stream.InternalMetadata.TryGetValue(LatestWorkflowCheckpointIdMetadataKey, out string? persistedCheckpointId) @@ -462,59 +461,12 @@ await this._toolboxService bool steeringDetected = false; bool deferredForRecovery = false; - async ValueTask PersistWorkflowCheckpointAsync( - CheckpointInfo checkpoint, - CancellationToken checkpointCancellationToken) - { - if (!isResilientTurn - || workflowCheckpointRecovery is null - || session is null - || string.IsNullOrWhiteSpace(agentSessionId) - || (stream.InternalMetadata.TryGetValue(LatestWorkflowCheckpointIdMetadataKey, out string? lastCheckpointId) - && string.Equals(lastCheckpointId, checkpoint.CheckpointId, StringComparison.Ordinal))) - { - return null; - } - - try - { - await sessionStore.SaveSessionAsync( - agent, - agentSessionId, - session, - resolvedUserId, - checkpointCancellationToken).ConfigureAwait(false); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - if (this._logger.IsEnabled(LogLevel.Debug)) - { - this._logger.LogDebug( - ex, - "Workflow checkpoint {CheckpointId} was not paired with response {ResponseId} because its AgentSession could not be saved.", - checkpoint.CheckpointId, - context.ResponseId); - } - - return null; - } - - stream.InternalMetadata[LatestWorkflowCheckpointIdMetadataKey] = checkpoint.CheckpointId; - return stream.EmitInProgress(); - } - - // Check whenever the agent is storing messages when it should not. - bool CheckNotAllowedStoreUsage() => - // For IChatClients implementations when the backend is set to not store (store = false) the returned responseMessage.ConversationId comes null. - // If for any reason this property is set it means that the storage setting was enabled when it shouldn't. - !allowStoredOutputEnabled && session is ChatClientAgentSession { ConversationId: not null }; - - var enumerator = OutputConverter.ConvertUpdatesToEventsAsync( + var enumerator = OutputConverter.ConvertUpdatesToItemsAsync( agent.RunStreamingAsync(messages, session, options: options, cancellationToken: consentCts.Token), stream, session?.StateBag, - persistWorkflowCheckpointHandler: PersistWorkflowCheckpointAsync, cancellationToken: cancellationToken).GetAsyncEnumerator(cancellationToken); + var pendingEvents = new Queue(); try { while (true) @@ -530,12 +482,41 @@ bool CheckNotAllowedStoreUsage() => ResponseStreamEvent? evt = null; try { - if (!await enumerator.MoveNextAsync().ConfigureAwait(false)) + if (!pendingEvents.TryDequeue(out evt)) { - break; + if (!await enumerator.MoveNextAsync().ConfigureAwait(false)) + { + break; + } + + switch (enumerator.Current) + { + case OutputConverterItem.ResponseEvent responseEvent: + evt = responseEvent.Event; + break; + + case OutputConverterItem.WorkflowCheckpoint workflowCheckpoint: + ResponseStreamEvent? checkpointStateEvent = + await PersistWorkflowCheckpointAsync( + workflowCheckpoint.Checkpoint, + cancellationToken).ConfigureAwait(false); + + if (checkpointStateEvent is not null) + { + // AgentServer persists its orchestrator-owned response snapshot. + // Apply the updated internal metadata first, then request persistence. + pendingEvents.Enqueue(checkpointStateEvent); + pendingEvents.Enqueue(stream.Checkpoint()); + } + + continue; + + default: + throw new InvalidOperationException( + "The output converter returned an unsupported item."); + } } - evt = enumerator.Current; if (evt is ResponseCompletedEvent) { consentCts.Token.ThrowIfCancellationRequested(); @@ -665,8 +646,8 @@ bool CheckNotAllowedStoreUsage() => // remains authoritative for a turn that reaches normal completion. if (isResilientTurn && evt is ResponseOutputItemDoneEvent - && workflowCheckpointRecovery is null && session is not null + && session.GetService() is null && !string.IsNullOrWhiteSpace(agentSessionId) && !turnFailed) { @@ -732,6 +713,53 @@ await sessionStore.SaveSessionAsync( { yield return completedEvent; } + + async ValueTask PersistWorkflowCheckpointAsync( + CheckpointInfo checkpoint, + CancellationToken checkpointCancellationToken) + { + if (!isResilientTurn + || session is null + || session.GetService() is null + || string.IsNullOrWhiteSpace(agentSessionId) + || (stream.InternalMetadata.TryGetValue(LatestWorkflowCheckpointIdMetadataKey, out string? lastCheckpointId) + && string.Equals(lastCheckpointId, checkpoint.CheckpointId, StringComparison.Ordinal))) + { + return null; + } + + try + { + await sessionStore.SaveSessionAsync( + agent, + agentSessionId, + session, + resolvedUserId, + checkpointCancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + if (this._logger.IsEnabled(LogLevel.Debug)) + { + this._logger.LogDebug( + ex, + "Workflow checkpoint {CheckpointId} was not paired with response {ResponseId} because its AgentSession could not be saved.", + checkpoint.CheckpointId, + context.ResponseId); + } + + return null; + } + + stream.InternalMetadata[LatestWorkflowCheckpointIdMetadataKey] = checkpoint.CheckpointId; + return stream.EmitInProgress(); + } + + // Check whenever the agent is storing messages when it should not. + bool CheckNotAllowedStoreUsage() => + // For IChatClients implementations when the backend is set to not store (store = false) the returned responseMessage.ConversationId comes null. + // If for any reason this property is set it means that the storage setting was enabled when it shouldn't. + !allowStoredOutputEnabled && session is ChatClientAgentSession { ConversationId: not null }; } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs index e7506da5f1e..2a4682b5296 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs @@ -26,6 +26,16 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; +internal abstract record OutputConverterItem +{ + public sealed record ResponseEvent(ResponseStreamEvent Event) : OutputConverterItem; + + public sealed record WorkflowCheckpoint(CheckpointInfo Checkpoint) : OutputConverterItem; + + public static implicit operator OutputConverterItem(ResponseStreamEvent value) => + new ResponseEvent(value); +} + /// /// Converts agent-framework streams into /// Responses Server SDK sequences using the @@ -40,18 +50,44 @@ internal static class OutputConverter /// The agent response updates to convert. /// The SDK event stream builder. /// Optional session state bag used to persist tool-approval id mappings across turns. - /// - /// Optional callback invoked after all output from a completed workflow superstep has been closed. - /// /// Cancellation token. - /// An async enumerable of SDK response stream events (excluding lifecycle events). + /// + /// An async enumerable of SDK response stream events. Workflow checkpoint boundaries are omitted. + /// + public static async IAsyncEnumerable ConvertUpdatesToEventsAsync( + IAsyncEnumerable updates, + ResponseEventStream stream, + AgentSessionStateBag? stateBag = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (OutputConverterItem item in ConvertUpdatesToItemsAsync( + updates, + stream, + stateBag, + cancellationToken).ConfigureAwait(false)) + { + if (item is OutputConverterItem.ResponseEvent responseEvent) + { + yield return responseEvent.Event; + } + } + } + + /// + /// Converts a stream of into response events and workflow + /// checkpoint boundaries. + /// + /// The agent response updates to convert. + /// The SDK event stream builder. + /// Optional session state bag used to persist tool-approval id mappings across turns. + /// Cancellation token. + /// Converted response events and workflow checkpoint boundaries in source order. [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing function call arguments dictionary.")] [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing function call arguments dictionary.")] - public static async IAsyncEnumerable ConvertUpdatesToEventsAsync( + public static async IAsyncEnumerable ConvertUpdatesToItemsAsync( IAsyncEnumerable updates, ResponseEventStream stream, AgentSessionStateBag? stateBag = null, - Func>? persistWorkflowCheckpointHandler = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { ResponseUsage? accumulatedUsage = null; @@ -90,19 +126,12 @@ public static async IAsyncEnumerable ConvertUpdatesToEvents yield return evt; } - if (workflowEvent is SuperStepCompletedEvent { CompletionInfo.Checkpoint: { } checkpoint } - && persistWorkflowCheckpointHandler is not null) - { - ResponseStreamEvent? checkpointStateEvent = - await persistWorkflowCheckpointHandler(checkpoint, cancellationToken).ConfigureAwait(false); - if (checkpointStateEvent is not null) + if (workflowEvent is SuperStepCompletedEvent { - // AgentServer persists its orchestrator-owned response snapshot. Emit the - // updated response state first so internal metadata becomes part of that - // authoritative snapshot, then persist it with the control event. - yield return checkpointStateEvent; - yield return stream.Checkpoint(); - } + CompletionInfo.Checkpoint: { } checkpoint + }) + { + yield return new OutputConverterItem.WorkflowCheckpoint(checkpoint); } continue; diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterWorkflowTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterWorkflowTests.cs index 5cd73404f86..9264c3341a4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterWorkflowTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterWorkflowTests.cs @@ -19,6 +19,33 @@ namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; /// public class OutputConverterWorkflowTests { + [Fact] + public async Task ConvertUpdatesToItemsAsync_SuperStepCheckpoint_EmitsCheckpointBoundaryAsync() + { + // Arrange + var (stream, _) = CreateTestStream(); + var checkpoint = new CheckpointInfo("workflow-session", "checkpoint-1"); + var completion = new SuperStepCompletionInfo([]) { Checkpoint = checkpoint }; + var update = new AgentResponseUpdate + { + RawRepresentation = new SuperStepCompletedEvent(1, completion) + }; + + // Act + var items = new List(); + await foreach (OutputConverterItem item in OutputConverter.ConvertUpdatesToItemsAsync( + ToAsync([update]), + stream)) + { + items.Add(item); + } + + // Assert + OutputConverterItem.WorkflowCheckpoint boundary = + Assert.Single(items.OfType()); + Assert.Same(checkpoint, boundary.Checkpoint); + } + [Fact] public async Task SequentialWorkflowPattern_ProducesCorrectEventsAsync() { From 18ae04ab7a43f1d6c39d4ea2b2ba1950d07dbf71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=87=E5=AE=99=E7=9A=84=E5=B1=B1=E5=B3=B0?= Date: Mon, 7 Sep 2026 16:50:08 +0000 Subject: [PATCH 57/85] .NET: Include workflow outputs in hosted agent responses (#8020) * Fix hosted workflow responses to include outputs * .NET: Expose workflow output option in hosting --- .../DevUI/DevUI_Step01_BasicUsage/Program.cs | 2 +- .../DevUI/DevUI_Step01_BasicUsage/README.md | 9 +++- .../HostedWorkflowBuilderExtensions.cs | 19 ++++++-- .../ChatMessageOutputWorkflow.cs | 33 ++++++++++++++ ...plicationBuilderWorkflowExtensionsTests.cs | 44 +++++++++++++++++++ 5 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/ChatMessageOutputWorkflow.cs diff --git a/dotnet/samples/02-agents/DevUI/DevUI_Step01_BasicUsage/Program.cs b/dotnet/samples/02-agents/DevUI/DevUI_Step01_BasicUsage/Program.cs index f7428fa4f3e..7ab48835813 100644 --- a/dotnet/samples/02-agents/DevUI/DevUI_Step01_BasicUsage/Program.cs +++ b/dotnet/samples/02-agents/DevUI/DevUI_Step01_BasicUsage/Program.cs @@ -90,7 +90,7 @@ static string GetCurrentTime() { var agents = new List() { assistantBuilder, reviewerBuilder }.Select(ab => sp.GetRequiredKeyedService(ab.Name)); return AgentWorkflowBuilder.BuildSequential(workflowName: key, agents: agents); - }).AddAsAIAgent(); + }).AddAsAIAgent(includeWorkflowOutputsInResponse: true); builder.Services.AddOpenAIResponses(); builder.Services.AddOpenAIConversations(); diff --git a/dotnet/samples/02-agents/DevUI/DevUI_Step01_BasicUsage/README.md b/dotnet/samples/02-agents/DevUI/DevUI_Step01_BasicUsage/README.md index 48deaad94e7..d46b1a3905d 100644 --- a/dotnet/samples/02-agents/DevUI/DevUI_Step01_BasicUsage/README.md +++ b/dotnet/samples/02-agents/DevUI/DevUI_Step01_BasicUsage/README.md @@ -60,9 +60,16 @@ To add DevUI to your ASP.NET Core application: var agent1Builder = builder.AddAIAgent("workflow-agent1", "You are agent 1."); var agent2Builder = builder.AddAIAgent("workflow-agent2", "You are agent 2."); builder.AddSequentialWorkflow("my-workflow", [agent1Builder, agent2Builder]) - .AddAsAIAgent(); + .AddAsAIAgent(includeWorkflowOutputsInResponse: true); ``` + Set `includeWorkflowOutputsInResponse` to `true` to include the workflow's final output in the + hosted agent response. This is required when the workflow is exposed through + `MapOpenAIResponses()` or `MapOpenAIConversations()`; otherwise the output can be visible in + streaming workflow events while the completed response has no output items. If you create an + agent directly from a `Workflow`, opt in with + `workflow.AsAIAgent(includeWorkflowOutputsInResponse: true)`. + 3. Add OpenAI services and map the endpoints for OpenAI and DevUI: ```csharp // Register services for OpenAI responses and conversations (also required for DevUI) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilderExtensions.cs index abee1cb566f..c29705b0f9f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilderExtensions.cs @@ -15,9 +15,13 @@ public static class HostedWorkflowBuilderExtensions /// /// The instance to extend. /// The DI service lifetime for the agent registration. Defaults to . + /// If , workflow outputs are included in the agent response. /// An that can be used to further configure the agent. - public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder builder, ServiceLifetime lifetime = ServiceLifetime.Singleton) - => builder.AddAsAIAgent(name: null, lifetime: lifetime); + public static IHostedAgentBuilder AddAsAIAgent( + this IHostedWorkflowBuilder builder, + ServiceLifetime lifetime = ServiceLifetime.Singleton, + bool includeWorkflowOutputsInResponse = false) + => builder.AddAsAIAgent(name: null, lifetime: lifetime, includeWorkflowOutputsInResponse: includeWorkflowOutputsInResponse); /// /// Registers the workflow as an AI agent in the dependency injection container. @@ -25,13 +29,20 @@ public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder build /// The instance to extend. /// The optional name for the AI agent. If not specified, the workflow name is used. /// The DI service lifetime for the agent registration. Defaults to . + /// If , workflow outputs are included in the agent response. /// An that can be used to further configure the agent. - public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder builder, string? name, ServiceLifetime lifetime = ServiceLifetime.Singleton) + public static IHostedAgentBuilder AddAsAIAgent( + this IHostedWorkflowBuilder builder, + string? name, + ServiceLifetime lifetime = ServiceLifetime.Singleton, + bool includeWorkflowOutputsInResponse = false) { var workflowName = builder.Name; var agentName = name ?? workflowName; return builder.HostApplicationBuilder.AddAIAgent(agentName, (sp, key) => - sp.GetRequiredKeyedService(workflowName).AsAIAgent(name: key), lifetime); + sp.GetRequiredKeyedService(workflowName).AsAIAgent( + name: key, + includeWorkflowOutputsInResponse: includeWorkflowOutputsInResponse), lifetime); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/ChatMessageOutputWorkflow.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/ChatMessageOutputWorkflow.cs new file mode 100644 index 00000000000..64d16beefed --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/ChatMessageOutputWorkflow.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.UnitTests; + +internal static class ChatMessageOutputWorkflow +{ + internal static Workflow Build(string name) + { + var output = new OutputExecutor("output"); + return new WorkflowBuilder(output) + .WithName(name) + .WithOutputFrom(output) + .Build(); + } + + private sealed class OutputExecutor(string id) : ChatProtocolExecutor(id) + { + protected override ValueTask TakeTurnAsync( + List messages, + IWorkflowContext context, + bool? emitEvents, + CancellationToken cancellationToken = default) + => context.AddEventAsync( + new WorkflowOutputEvent(new ChatMessage(ChatRole.Assistant, "workflow output"), this.Id), + cancellationToken); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs index c17655bd29f..44cd39286f7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs @@ -2,7 +2,9 @@ using System; using System.Linq; +using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Moq; @@ -186,6 +188,48 @@ public void AddAsAIAgent_WithoutName_UsesWorkflowName() Assert.NotNull(agentDescriptor); } + /// + /// Verifies that a workflow registered as an AI agent includes its chat-message output in the response. + /// + [Fact] + public async Task AddAsAIAgent_IncludesWorkflowOutputInResponseAsync() + { + // Arrange + var builder = new HostApplicationBuilder(); + const string WorkflowName = "outputWorkflow"; + builder.AddWorkflow(WorkflowName, (sp, key) => ChatMessageOutputWorkflow.Build(key)) + .AddAsAIAgent(includeWorkflowOutputsInResponse: true); + using var host = builder.Build(); + AIAgent agent = host.Services.GetRequiredKeyedService(WorkflowName); + + // Act + AgentResponse response = await agent.RunAsync(new ChatMessage(ChatRole.User, "hello")); + + // Assert + Assert.Equal("workflow output", response.Text); + } + + /// + /// Verifies that a workflow registered as an AI agent excludes its output from the response by default. + /// + [Fact] + public async Task AddAsAIAgent_DefaultExcludesWorkflowOutputFromResponseAsync() + { + // Arrange + var builder = new HostApplicationBuilder(); + const string WorkflowName = "outputWorkflow"; + builder.AddWorkflow(WorkflowName, (sp, key) => ChatMessageOutputWorkflow.Build(key)) + .AddAsAIAgent(); + using var host = builder.Build(); + AIAgent agent = host.Services.GetRequiredKeyedService(WorkflowName); + + // Act + AgentResponse response = await agent.RunAsync(new ChatMessage(ChatRole.User, "hello")); + + // Assert + Assert.Empty(response.Messages); + } + /// /// Verifies that AddAsAIAgent with a name parameter uses that name instead of the workflow name. /// From fdf7e80ad6c8f6dbdd9b779d5d6923c74d8b583e Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:08:02 +0000 Subject: [PATCH 58/85] Improve inline skill argument error (#8118) Point users to the custom argument marshaler when a model supplies inline skill arguments in an unsupported JSON format. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: SergeyMenshykh Copilot-Session: 406e0b5c-da2c-40f2-9232-4ff2742aad2c --- .../Skills/Programmatic/AgentInlineSkillScript.cs | 3 ++- .../AgentSkills/SkillScriptArgumentMarshalerTests.cs | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs index b640e08d8e6..cce582b1be4 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs @@ -99,7 +99,8 @@ private static AIFunctionArguments ConvertToFunctionArguments(JsonElement? argum if (arguments.Value.ValueKind != JsonValueKind.Object) { throw new InvalidOperationException( - $"Inline skill scripts expect arguments as a JSON object but received a JSON element of kind '{arguments.Value.ValueKind}'."); + $"Inline skill scripts expect arguments as a JSON object but received a JSON element of kind '{arguments.Value.ValueKind}'. " + + "Configure a custom argument marshaler on the skill to handle other argument formats."); } var dict = new Dictionary(); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/SkillScriptArgumentMarshalerTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/SkillScriptArgumentMarshalerTests.cs index 325ba3f826c..bfc39ec3add 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/SkillScriptArgumentMarshalerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/SkillScriptArgumentMarshalerTests.cs @@ -99,6 +99,7 @@ public async Task DefaultMarshaler_StringArguments_ThrowsAsync() var ex = await Assert.ThrowsAsync( () => script.RunAsync(skill, element, null, CancellationToken.None)); Assert.Contains("String", ex.Message); + Assert.Contains("custom argument marshaler", ex.Message); } [Fact] From c02761294040e8fd5cf34f3718065fef2019798c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:29:14 +0000 Subject: [PATCH 59/85] Python: Build(deps-dev): Bump the basics group in /python with 3 updates (#8110) * Build(deps-dev): Bump the basics group in /python with 3 updates Bumps the basics group in /python with 3 updates: [uv](https://github.com/astral-sh/uv), [ruff](https://github.com/astral-sh/ruff) and [prek](https://github.com/j178/prek). Updates `uv` from 0.12.6 to 0.12.7 - [Release notes](https://github.com/astral-sh/uv/releases) - [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/uv/compare/0.12.6...0.12.7) Updates `ruff` from 0.16.4 to 0.16.5 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.16.4...0.16.5) Updates `prek` from 0.4.11 to 0.5.0 - [Release notes](https://github.com/j178/prek/releases) - [Changelog](https://github.com/j178/prek/blob/master/CHANGELOG.md) - [Commits](https://github.com/j178/prek/compare/v0.4.11...v0.5.0) --- updated-dependencies: - dependency-name: uv dependency-version: 0.12.7 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: basics - dependency-name: ruff dependency-version: 0.16.5 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: basics - dependency-name: prek dependency-version: 0.5.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: basics ... Signed-off-by: dependabot[bot] * Fix lab dependency pins and lockfile Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> Co-authored-by: Eduard van Valkenburg --- python/packages/lab/pyproject.toml | 6 +- python/pyproject.toml | 6 +- python/uv.lock | 1902 ++++++++++++++-------------- 3 files changed, 957 insertions(+), 957 deletions(-) diff --git a/python/packages/lab/pyproject.toml b/python/packages/lab/pyproject.toml index 53f885c22e5..71cf38e021f 100644 --- a/python/packages/lab/pyproject.toml +++ b/python/packages/lab/pyproject.toml @@ -57,8 +57,8 @@ math = [ [dependency-groups] dev = [ - "uv==0.12.6", - "ruff==0.16.4", + "uv==0.12.7", + "ruff==0.16.5", "pytest==9.1.1", "mypy==2.3.1", "pyright==1.1.411", @@ -67,7 +67,7 @@ dev = [ "rich>=13.7.1,<15.0.0", "tomli==2.4.1", "tomli-w==1.2.0", - "prek==0.4.11", + "prek==0.5.0", ] # tau2 is an executable optional feature fetched from source because it is not available on PyPI. tau2 = [ diff --git a/python/pyproject.toml b/python/pyproject.toml index f9b168f6470..3d03502029c 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -28,9 +28,9 @@ dependencies = [ [dependency-groups] dev = [ - "uv==0.12.6", + "uv==0.12.7", "flit==4.0.2", - "ruff==0.16.4", + "ruff==0.16.5", "pytest==9.1.1", "pytest-asyncio==1.4.0", "pytest-cov==7.1.0", @@ -47,7 +47,7 @@ dev = [ "poethepoet==0.48.0", "rich>=13.7.1,<16.0.0", "tomli==2.4.1", - "prek==0.4.11", + "prek==0.5.0", ] test = [ "azure-monitor-opentelemetry", diff --git a/python/uv.lock b/python/uv.lock index 529311c8725..a38e3b6ba11 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -78,14 +78,14 @@ name = "a2a-sdk" version = "1.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "culsans", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, - { name = "google-api-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "googleapis-common-protos", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "json-rpc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "culsans", marker = "python_full_version < '3.13'" }, + { name = "google-api-core" }, + { name = "googleapis-common-protos" }, + { name = "httpx" }, + { name = "json-rpc" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "pydantic" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/cc/59b35c518d8289bd59d20d9d216ca29ccb41c4697eb85971efe41d1adaf3/a2a_sdk-1.1.2.tar.gz", hash = "sha256:f928d8bf9a0dc0a473ee8258bd5226c1b8520a85c70e7f233c3b9b0e30a1bd10", size = 379627, upload-time = "2026-07-22T13:40:51.409Z" } wheels = [ @@ -115,7 +115,7 @@ name = "ag-ui-protocol" version = "0.1.19" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/10/4ad299267a7d04b89935aa99eef62979758fcf95aee9f8bb5d70c35b1be1/ag_ui_protocol-0.1.19.tar.gz", hash = "sha256:43c27f60d41712dcad0e9e0a203cbdf1c8e248b22417374c5c68321c448af4ea", size = 10720, upload-time = "2026-06-02T17:26:15.627Z" } wheels = [ @@ -127,35 +127,35 @@ name = "agent-framework" version = "1.17.0" source = { virtual = "." } dependencies = [ - { name = "agent-framework-core", extra = ["all"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core", extra = ["all"] }, ] [package.dev-dependencies] dev = [ - { name = "flit", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "mypy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "poethepoet", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "prek", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyrefly", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyright", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest-asyncio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest-cov", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest-retry", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest-timeout", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest-xdist", extra = ["psutil"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "ruff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tomli", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "ty", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "zuban", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "flit" }, + { name = "mypy" }, + { name = "opentelemetry-sdk" }, + { name = "poethepoet" }, + { name = "prek" }, + { name = "pyrefly" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-retry" }, + { name = "pytest-timeout" }, + { name = "pytest-xdist", extra = ["psutil"] }, + { name = "rich" }, + { name = "ruff" }, + { name = "tomli" }, + { name = "ty" }, + { name = "uv" }, + { name = "zuban" }, ] test = [ - { name = "agent-hooks-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-monitor-opentelemetry", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-hooks-sdk" }, + { name = "azure-monitor-opentelemetry" }, + { name = "mcp", extra = ["ws"] }, ] [package.metadata] @@ -167,7 +167,7 @@ dev = [ { name = "mypy", specifier = "==2.3.1" }, { name = "opentelemetry-sdk" }, { name = "poethepoet", specifier = "==0.48.0" }, - { name = "prek", specifier = "==0.4.11" }, + { name = "prek", specifier = "==0.5.0" }, { name = "pyrefly", specifier = "==1.2.0" }, { name = "pyright", specifier = "==1.1.411" }, { name = "pytest", specifier = "==9.1.1" }, @@ -177,10 +177,10 @@ dev = [ { name = "pytest-timeout", specifier = "==2.4.0" }, { name = "pytest-xdist", extras = ["psutil"], specifier = "==3.8.0" }, { name = "rich", specifier = ">=13.7.1,<16.0.0" }, - { name = "ruff", specifier = "==0.16.4" }, + { name = "ruff", specifier = "==0.16.5" }, { name = "tomli", specifier = "==2.4.1" }, { name = "ty", specifier = "==0.0.75" }, - { name = "uv", specifier = "==0.12.6" }, + { name = "uv", specifier = "==0.12.7" }, { name = "zuban", specifier = "==0.9.2" }, ] test = [ @@ -194,8 +194,8 @@ name = "agent-framework-a2a" version = "1.0.0b260821" source = { editable = "packages/a2a" } dependencies = [ - { name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "a2a-sdk" }, + { name = "agent-framework-core" }, ] [package.metadata] @@ -209,25 +209,25 @@ name = "agent-framework-ag-ui" version = "1.2.2" source = { editable = "packages/ag-ui" } dependencies = [ - { name = "ag-ui-protocol", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "sse-starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uvicorn", extra = ["standard"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "ag-ui-protocol" }, + { name = "agent-framework-core" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "sse-starlette" }, + { name = "uvicorn", extra = ["standard"] }, ] [package.optional-dependencies] a2ui = [ - { name = "ag-ui-a2ui-toolkit", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "ag-ui-a2ui-toolkit" }, ] dev = [ - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pytest" }, ] [package.dev-dependencies] test = [ - { name = "agent-framework-orchestrations", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-orchestrations" }, ] [package.metadata] @@ -251,8 +251,8 @@ name = "agent-framework-anthropic" version = "1.0.0b260827" source = { editable = "packages/anthropic" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "anthropic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "anthropic" }, ] [package.metadata] @@ -266,8 +266,8 @@ name = "agent-framework-azure-ai-search" version = "1.0.0b260827" source = { editable = "packages/azure-ai-search" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-search-documents", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "azure-search-documents" }, ] [package.metadata] @@ -281,11 +281,11 @@ name = "agent-framework-azure-contentunderstanding" version = "1.0.0b260813" source = { editable = "packages/azure-contentunderstanding" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-foundry", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-ai-contentunderstanding", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "filetype", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "agent-framework-foundry" }, + { name = "aiohttp" }, + { name = "azure-ai-contentunderstanding" }, + { name = "filetype" }, ] [package.metadata] @@ -302,9 +302,9 @@ name = "agent-framework-azure-cosmos" version = "1.0.0b260821" source = { editable = "packages/azure-cosmos" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-cosmos", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "azure-cosmos" }, + { name = "six" }, ] [package.metadata] @@ -319,16 +319,16 @@ name = "agent-framework-azure-cosmos-memory" version = "1.0.0a260813" source = { editable = "packages/azure-cosmos-memory" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-cosmos-agent-memory", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "prompty", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "azure-cosmos-agent-memory" }, + { name = "prompty" }, ] [package.dev-dependencies] dev = [ - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest-asyncio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest-cov", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, ] [package.metadata] @@ -350,10 +350,10 @@ name = "agent-framework-azurefunctions" version = "1.0.0b260730" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-durabletask", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-functions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-functions-durable", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "agent-framework-durabletask" }, + { name = "azure-functions" }, + { name = "azure-functions-durable" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7f/51/358ca285536098786143113d6f683d4ee08316545503a039975e6a39e518/agent_framework_azurefunctions-1.0.0b260730.tar.gz", hash = "sha256:d686dad93d048e6409377c8b9c26c87aca62bbc2054240a752f1daaa18ee53cf", size = 33039, upload-time = "2026-07-30T23:38:44.926Z" } wheels = [ @@ -365,9 +365,9 @@ name = "agent-framework-bedrock" version = "1.0.0b260730" source = { editable = "packages/bedrock" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "boto3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "botocore", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "boto3" }, + { name = "botocore" }, ] [package.metadata] @@ -382,8 +382,8 @@ name = "agent-framework-chatkit" version = "1.0.0b260730" source = { editable = "packages/chatkit" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "openai-chatkit", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "openai-chatkit" }, ] [package.metadata] @@ -397,8 +397,8 @@ name = "agent-framework-claude" version = "1.0.0b260813" source = { editable = "packages/claude" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "claude-agent-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "claude-agent-sdk" }, ] [package.metadata] @@ -412,10 +412,10 @@ name = "agent-framework-copilotstudio" version = "1.0.0b260813" source = { editable = "packages/copilotstudio" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "microsoft-agents-copilotstudio-client", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "msal", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "aiohttp" }, + { name = "microsoft-agents-copilotstudio-client" }, + { name = "msal" }, ] [package.metadata] @@ -431,51 +431,51 @@ name = "agent-framework-core" version = "1.17.0" source = { editable = "packages/core" } dependencies = [ - { name = "msgspec", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "msgspec" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, ] [package.optional-dependencies] all = [ - { name = "agent-framework-a2a", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-ag-ui", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-anthropic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-azure-ai-search", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-azure-contentunderstanding", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-azure-cosmos", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-azurefunctions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-bedrock", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-chatkit", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-claude", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-copilotstudio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-declarative", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-devui", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-durabletask", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-foundry", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-foundry-hosting", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-foundry-local", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-gemini", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-github-copilot", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-a2a" }, + { name = "agent-framework-ag-ui" }, + { name = "agent-framework-anthropic" }, + { name = "agent-framework-azure-ai-search" }, + { name = "agent-framework-azure-contentunderstanding" }, + { name = "agent-framework-azure-cosmos" }, + { name = "agent-framework-azurefunctions" }, + { name = "agent-framework-bedrock" }, + { name = "agent-framework-chatkit" }, + { name = "agent-framework-claude" }, + { name = "agent-framework-copilotstudio" }, + { name = "agent-framework-declarative" }, + { name = "agent-framework-devui" }, + { name = "agent-framework-durabletask" }, + { name = "agent-framework-foundry" }, + { name = "agent-framework-foundry-hosting" }, + { name = "agent-framework-foundry-local" }, + { name = "agent-framework-gemini" }, + { name = "agent-framework-github-copilot" }, { name = "agent-framework-hyperlight", marker = "(python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.14' and platform_machine == 'AMD64' and sys_platform == 'win32')" }, - { name = "agent-framework-lab", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-mem0", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-mistral", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-monty", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-ollama", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-orchestrations", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-purview", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-redis", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-tools", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-lab" }, + { name = "agent-framework-mem0" }, + { name = "agent-framework-mistral" }, + { name = "agent-framework-monty" }, + { name = "agent-framework-ollama" }, + { name = "agent-framework-openai" }, + { name = "agent-framework-orchestrations" }, + { name = "agent-framework-purview" }, + { name = "agent-framework-redis" }, + { name = "agent-framework-tools" }, + { name = "mcp", extra = ["ws"] }, ] [package.dev-dependencies] dev = [ - { name = "azure-ai-agentserver-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-ai-agentserver-core" }, ] [package.metadata] @@ -527,15 +527,15 @@ name = "agent-framework-declarative" version = "1.0.3" source = { editable = "packages/declarative" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "powerfx", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, - { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "httpx" }, + { name = "powerfx", marker = "python_full_version < '3.14'" }, + { name = "pyyaml" }, ] [package.dev-dependencies] dev = [ - { name = "types-pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "types-pyyaml" }, ] [package.metadata] @@ -554,22 +554,22 @@ name = "agent-framework-devui" version = "1.0.0b260903" source = { editable = "packages/devui" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uvicorn", extra = ["standard"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "fastapi" }, + { name = "openai" }, + { name = "opentelemetry-sdk" }, + { name = "uvicorn", extra = ["standard"] }, ] [package.optional-dependencies] all = [ - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "watchdog", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pytest" }, + { name = "watchdog" }, ] dev = [ - { name = "agent-framework-orchestrations", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "watchdog", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-orchestrations" }, + { name = "pytest" }, + { name = "watchdog" }, ] [package.metadata] @@ -592,10 +592,10 @@ name = "agent-framework-durabletask" version = "1.0.0b260730" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "durabletask", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "durabletask-azuremanaged", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "durabletask" }, + { name = "durabletask-azuremanaged" }, + { name = "python-dateutil" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/e0/5113b292a854046e6af90cb869d9eb6f2f4c611bef10e9cbabcb138d00ee/agent_framework_durabletask-1.0.0b260730.tar.gz", hash = "sha256:4c6ed98f7fedbd7733f110991a8eea9fe7254ca56d12e5198efd8991251698f2", size = 72176, upload-time = "2026-07-30T23:38:59.338Z" } wheels = [ @@ -607,12 +607,12 @@ name = "agent-framework-foundry" version = "1.12.0" source = { editable = "packages/foundry" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-ai-inference", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-ai-projects", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "agent-framework-openai" }, + { name = "aiohttp" }, + { name = "azure-ai-inference" }, + { name = "azure-ai-projects" }, + { name = "httpx" }, ] [package.metadata] @@ -630,12 +630,12 @@ name = "agent-framework-foundry-hosting" version = "1.0.0b260903" source = { editable = "packages/foundry_hosting" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-ai-agentserver-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-ai-agentserver-invocations", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-ai-agentserver-responses", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "azure-ai-agentserver-core" }, + { name = "azure-ai-agentserver-invocations" }, + { name = "azure-ai-agentserver-responses" }, + { name = "httpx" }, + { name = "mcp", extra = ["ws"] }, ] [package.metadata] @@ -653,9 +653,9 @@ name = "agent-framework-foundry-local" version = "1.0.0b260730" source = { editable = "packages/foundry_local" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "foundry-local-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "agent-framework-openai" }, + { name = "foundry-local-sdk" }, ] [package.metadata] @@ -670,8 +670,8 @@ name = "agent-framework-gemini" version = "1.0.0b260903" source = { editable = "packages/gemini" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "google-genai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "google-genai" }, ] [package.metadata] @@ -685,8 +685,8 @@ name = "agent-framework-github-copilot" version = "1.0.3" source = { editable = "packages/github_copilot" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "github-copilot-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "github-copilot-sdk" }, ] [package.metadata] @@ -700,7 +700,7 @@ name = "agent-framework-hosting" version = "1.0.0a260730" source = { editable = "packages/hosting" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, ] [package.metadata] @@ -711,9 +711,9 @@ name = "agent-framework-hosting-a2a" version = "1.0.0a260730" source = { editable = "packages/hosting-a2a" } dependencies = [ - { name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-hosting", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "a2a-sdk" }, + { name = "agent-framework-core" }, + { name = "agent-framework-hosting" }, ] [package.metadata] @@ -728,10 +728,10 @@ name = "agent-framework-hosting-mcp" version = "1.0.0a260730" source = { editable = "packages/hosting-mcp" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-hosting", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "agent-framework-hosting" }, + { name = "mcp", extra = ["ws"] }, + { name = "pydantic" }, ] [package.metadata] @@ -747,15 +747,15 @@ name = "agent-framework-hosting-responses" version = "1.0.0a260903" source = { editable = "packages/hosting-responses" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-hosting", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "agent-framework-hosting" }, + { name = "openai" }, ] [package.dev-dependencies] test = [ - { name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "fastapi" }, + { name = "httpx" }, ] [package.metadata] @@ -776,8 +776,8 @@ name = "agent-framework-hosting-telegram" version = "1.0.0a260730" source = { editable = "packages/hosting-telegram" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-hosting", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "agent-framework-hosting" }, ] [package.metadata] @@ -791,10 +791,10 @@ name = "agent-framework-hyperlight" version = "1.0.0b260730" source = { editable = "packages/hyperlight" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "hyperlight-sandbox", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "hyperlight-sandbox" }, { name = "hyperlight-sandbox-backend-wasm", marker = "(python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.14' and platform_machine == 'AMD64' and sys_platform == 'win32')" }, - { name = "hyperlight-sandbox-python-guest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "hyperlight-sandbox-python-guest" }, ] [package.metadata] @@ -810,48 +810,48 @@ name = "agent-framework-lab" version = "1.0.0b260730" source = { editable = "packages/lab" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, ] [package.optional-dependencies] gaia = [ - { name = "huggingface-hub", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "orjson", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyarrow", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "huggingface-hub" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "orjson" }, + { name = "pyarrow" }, + { name = "pydantic" }, + { name = "tqdm" }, ] lightning = [ - { name = "agentlightning", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agentlightning" }, ] math = [ - { name = "sympy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "sympy" }, ] tau2 = [ - { name = "loguru", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tiktoken", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "loguru" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pydantic" }, + { name = "tiktoken" }, ] [package.dev-dependencies] dev = [ - { name = "mypy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "poethepoet", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "prek", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyright", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "ruff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tomli", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tomli-w", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "mypy" }, + { name = "poethepoet" }, + { name = "prek" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "rich" }, + { name = "ruff" }, + { name = "tomli" }, + { name = "tomli-w" }, + { name = "uv" }, ] tau2 = [ - { name = "tau2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tau2" }, ] [package.metadata] @@ -877,14 +877,14 @@ provides-extras = ["gaia", "lightning", "tau2", "math"] dev = [ { name = "mypy", specifier = "==2.3.1" }, { name = "poethepoet", specifier = "==0.48.0" }, - { name = "prek", specifier = "==0.4.11" }, + { name = "prek", specifier = "==0.5.0" }, { name = "pyright", specifier = "==1.1.411" }, { name = "pytest", specifier = "==9.1.1" }, { name = "rich", specifier = ">=13.7.1,<15.0.0" }, - { name = "ruff", specifier = "==0.16.4" }, + { name = "ruff", specifier = "==0.16.5" }, { name = "tomli", specifier = "==2.4.1" }, { name = "tomli-w", specifier = "==1.2.0" }, - { name = "uv", specifier = "==0.12.6" }, + { name = "uv", specifier = "==0.12.7" }, ] tau2 = [{ name = "tau2", git = "https://github.com/sierra-research/tau2-bench?rev=5ba9e3e56db57c5e4114bf7f901291f09b2c5619" }] @@ -893,8 +893,8 @@ name = "agent-framework-mem0" version = "1.0.0b260813" source = { editable = "packages/mem0" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "mem0ai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "mem0ai" }, ] [package.metadata] @@ -908,8 +908,8 @@ name = "agent-framework-mistral" version = "1.0.0b260903" source = { editable = "packages/mistral" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "mistralai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "mistralai" }, ] [package.metadata] @@ -923,8 +923,8 @@ name = "agent-framework-monty" version = "1.0.0b260730" source = { editable = "packages/monty" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic-monty", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "pydantic-monty" }, ] [package.metadata] @@ -938,8 +938,8 @@ name = "agent-framework-ollama" version = "1.0.0b260813" source = { editable = "packages/ollama" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "ollama", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "ollama" }, ] [package.metadata] @@ -953,8 +953,8 @@ name = "agent-framework-openai" version = "1.14.2" source = { editable = "packages/openai" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "openai" }, ] [package.metadata] @@ -968,7 +968,7 @@ name = "agent-framework-orchestrations" version = "1.1.1" source = { editable = "packages/orchestrations" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, ] [package.metadata] @@ -979,9 +979,9 @@ name = "agent-framework-purview" version = "1.0.0b260730" source = { editable = "packages/purview" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "azure-core" }, + { name = "httpx" }, ] [package.metadata] @@ -996,11 +996,11 @@ name = "agent-framework-redis" version = "1.0.0b260903" source = { editable = "packages/redis" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, - { name = "redis", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "redisvl", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "redis" }, + { name = "redisvl" }, ] [package.metadata] @@ -1016,8 +1016,8 @@ name = "agent-framework-tools" version = "1.0.0b260730" source = { editable = "packages/tools" } dependencies = [ - { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-core" }, + { name = "psutil" }, ] [package.metadata] @@ -1044,26 +1044,26 @@ name = "agentlightning" version = "0.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "agentops", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "aiologic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "flask", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "gpustat", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "graphviz", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "gunicorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "litellm", extra = ["proxy"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-exporter-otlp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "portpicker", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "setproctitle", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uvicorn", extra = ["standard"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uvicorn-worker", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agentops" }, + { name = "aiohttp" }, + { name = "aiologic" }, + { name = "fastapi" }, + { name = "flask" }, + { name = "gpustat" }, + { name = "graphviz" }, + { name = "gunicorn" }, + { name = "litellm", extra = ["proxy"] }, + { name = "openai" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp" }, + { name = "opentelemetry-sdk" }, + { name = "portpicker" }, + { name = "psutil" }, + { name = "pydantic" }, + { name = "rich" }, + { name = "setproctitle" }, + { name = "uvicorn", extra = ["standard"] }, + { name = "uvicorn-worker" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b2/8f/1bed06f70d52ba4b2ed698605fa955a82ee5aaec3addee5a21e3fd7cd0cb/agentlightning-0.3.0.tar.gz", hash = "sha256:35cd702bce54ff7c8c097d8e73aaf688c6649e4ee81e6e5e0379000465b75d43", size = 1345454, upload-time = "2025-12-24T01:49:31.24Z" } wheels = [ @@ -1075,20 +1075,20 @@ name = "agentops" version = "0.4.21" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-exporter-otlp-proto-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "ordered-set", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "termcolor", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "aiohttp" }, + { name = "httpx" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-sdk" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "ordered-set" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "termcolor" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0a/c4/023fe976169c57b1edd71f4c08d6dedaf66814f5b25ecf59b3a8540311ab/agentops-0.4.21.tar.gz", hash = "sha256:47759c6dfd6ea58bad2f7764257e4778cb2e34ae180cef642f60f56adced6510", size = 430861, upload-time = "2025-08-29T06:36:55.323Z" } wheels = [ @@ -1118,14 +1118,14 @@ name = "aiohttp" version = "3.14.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohappyeyeballs", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "aiosignal", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "attrs", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "frozenlist", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "multidict", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "propcache", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, - { name = "yarl", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } wheels = [ @@ -1232,9 +1232,9 @@ name = "aiologic" version = "0.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sniffio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, - { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f1/7a/d51f2fde1e8ae8a83431f8e97b7a71e9358cdb1d4d2ce6be387fa44d68de/aiologic-0.17.1.tar.gz", hash = "sha256:2e1b93b9e88ced318c2a63ad7b382688f40cbfe40e3d42258d49dc9c5aea179d", size = 252354, upload-time = "2026-06-27T20:41:33.25Z" } wheels = [ @@ -1246,8 +1246,8 @@ name = "aiosignal" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "frozenlist", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } wheels = [ @@ -1286,14 +1286,14 @@ name = "anthropic" version = "0.116.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "distro", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "docstring-parser", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "jiter", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "sniffio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/a2/d31f14e28d49bae983a3634e38dfb4b31c50110b5e403596c5c6a20b23f8/anthropic-0.116.0.tar.gz", hash = "sha256:5fc248fbb9fe03ef686f8a774f81586bca31a043260aab88b387ea3660f4a396", size = 949149, upload-time = "2026-07-02T19:08:10.534Z" } wheels = [ @@ -1305,8 +1305,8 @@ name = "anyio" version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ @@ -1327,7 +1327,7 @@ name = "apscheduler" version = "3.11.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "tzlocal", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tzlocal" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8c/6b/eeff360196bb20b312c9e762a820fd1b2c6d809466c755ef57863478e454/apscheduler-3.11.3.tar.gz", hash = "sha256:cd2fcc9330039a81a5893472ad49facf23a6d5604cbe1d918c835c6de7834d5a", size = 110312, upload-time = "2026-06-28T19:39:22.493Z" } wheels = [ @@ -1439,15 +1439,15 @@ name = "azure-ai-agentserver-core" version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "hypercorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "microsoft-opentelemetry", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "aiohttp" }, + { name = "azure-core" }, + { name = "azure-identity" }, + { name = "hypercorn" }, + { name = "isodate" }, + { name = "microsoft-opentelemetry" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "starlette" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e3/8d/cd4e4231b35cb966def75ed9e63d16704423d07b29e6ca9474921caf5751/azure_ai_agentserver_core-2.1.0.tar.gz", hash = "sha256:b4d6422357a03baa2c74e86fe121473aed99669a3f5f5f4904812c213038eb0f", size = 381781, upload-time = "2026-08-26T05:11:20.804Z" } wheels = [ @@ -1459,9 +1459,9 @@ name = "azure-ai-agentserver-invocations" version = "1.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-ai-agentserver-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "aiohttp" }, + { name = "azure-ai-agentserver-core" }, + { name = "azure-core" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ea/fc/e419daf6e426dbfd85697f933f7fe5e7de6ee7f894b760a2b237d0768ef8/azure_ai_agentserver_invocations-1.1.0.tar.gz", hash = "sha256:598ad66779283d3b397b3c882075b8c1a32f745e517340e67e47f428646df3ed", size = 150610, upload-time = "2026-08-26T06:42:07.087Z" } wheels = [ @@ -1473,10 +1473,10 @@ name = "azure-ai-agentserver-responses" version = "2.2.0b1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-ai-agentserver-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "aiohttp" }, + { name = "azure-ai-agentserver-core" }, + { name = "azure-core" }, + { name = "isodate" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b6/c9/cd74f32bd3005b2024be1144ff92f8091aa09fee2d330f38f780d06c1eec/azure_ai_agentserver_responses-2.2.0b1.tar.gz", hash = "sha256:0778bf08c1457bffbd217452b057c7d61930da839a0e162b80ae6534893c89e9", size = 684107, upload-time = "2026-08-27T19:14:37.171Z" } wheels = [ @@ -1488,9 +1488,9 @@ name = "azure-ai-contentunderstanding" version = "1.2.0b3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7d/c2/5190dd2f06892653fc6019535412a80f57b94b39c31c3de463a1e62f73ac/azure_ai_contentunderstanding-1.2.0b3.tar.gz", hash = "sha256:788390c8e852e0213d175d8b98514d7d3863e4e29f53a35c8a19d23a5a4a0a77", size = 342750, upload-time = "2026-08-11T03:17:23.71Z" } wheels = [ @@ -1502,9 +1502,9 @@ name = "azure-ai-inference" version = "1.0.0b9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4e/6a/ed85592e5c64e08c291992f58b1a94dab6869f28fb0f40fd753dced73ba6/azure_ai_inference-1.0.0b9.tar.gz", hash = "sha256:1feb496bd84b01ee2691befc04358fa25d7c344d8288e99364438859ad7cd5a4", size = 182408, upload-time = "2025-02-15T00:37:28.464Z" } wheels = [ @@ -1516,12 +1516,12 @@ name = "azure-ai-projects" version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-storage-blob", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core" }, + { name = "azure-identity" }, + { name = "azure-storage-blob" }, + { name = "isodate" }, + { name = "openai" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/29/ab1a80ce483fcc36ec2ac0a3db8e043aa954700b34b77db43ed5f4f9c488/azure_ai_projects-2.3.0.tar.gz", hash = "sha256:6e3006b7b8aa51c6ff9db61ef4aac3717f8a712cd1a183d5a1d34e2eb33450bd", size = 27563233, upload-time = "2026-07-01T21:09:00.018Z" } wheels = [ @@ -1533,8 +1533,8 @@ name = "azure-core" version = "1.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "requests" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a6/f3/b416179e408990df5db0d516283022dde0f5d0111d98c1a848e41853e81c/azure_core-1.41.0.tar.gz", hash = "sha256:f46ff5dfcd230f25cf1c19e8a34b8dc08a337b2503e268bb600a16c00db8ad5a", size = 381042, upload-time = "2026-05-07T23:30:54.302Z" } wheels = [ @@ -1546,8 +1546,8 @@ name = "azure-core-tracing-opentelemetry" version = "1.0.0b13" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core" }, + { name = "opentelemetry-api" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ce/ab/a937e4af8afec9d437d55252f2a3a4419fc3fc7d5e5d54022622bd11b2b6/azure_core_tracing_opentelemetry-1.0.0b13.tar.gz", hash = "sha256:6cb2f8dfd5dee6c11843db0205fc92e2434e1a272c169c953afe92483aafc7eb", size = 25832, upload-time = "2026-05-01T00:59:57.941Z" } wheels = [ @@ -1559,8 +1559,8 @@ name = "azure-cosmos" version = "4.16.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/35/21/59863ec67ff0284e6783fef67e2a03e17c3504a3ce0561d525ffb0caccec/azure_cosmos-4.16.3.tar.gz", hash = "sha256:da0a4d9957de7b3f018b2ebb728b7e0494623d4db58639eb92ccd140eddc12d3", size = 2459773, upload-time = "2026-07-29T22:39:46.757Z" } wheels = [ @@ -1572,15 +1572,15 @@ name = "azure-cosmos-agent-memory" version = "0.3.0b1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-cosmos", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "jinja2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "prompty", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tiktoken", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "aiohttp" }, + { name = "azure-cosmos" }, + { name = "azure-identity" }, + { name = "jinja2" }, + { name = "openai" }, + { name = "prompty" }, + { name = "pydantic" }, + { name = "tiktoken" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/71/85/f66f7e5989125428eba72917224fbdb4fb22671de3a39ec8d24446f93b2b/azure_cosmos_agent_memory-0.3.0b1.tar.gz", hash = "sha256:4dd017bd4c69895f3e053511c549ee8b215e5eb98f684152cf852ebbb69199b3", size = 169559, upload-time = "2026-07-24T21:37:19.433Z" } wheels = [ @@ -1592,7 +1592,7 @@ name = "azure-functions" version = "1.25.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "werkzeug", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "werkzeug" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6f/eb/745418bd7ae135068a88dcd2e0b3cefd526068a7bbe55cc29d5fb5919080/azure_functions-1.25.0.tar.gz", hash = "sha256:3c737657110b6cfd14e6871515ab66c377b9ceecaca8dcbf5b391d2433424756", size = 143938, upload-time = "2026-08-05T19:12:50.176Z" } wheels = [ @@ -1604,13 +1604,13 @@ name = "azure-functions-durable" version = "1.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-functions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "furl", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "aiohttp" }, + { name = "azure-functions" }, + { name = "furl" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "python-dateutil" }, + { name = "requests" }, ] sdist = { url = "https://files.pythonhosted.org/packages/79/6b/b72e813b6b5bd3f9a4fa5282df69c00834e3a094efb9fe9fdd1213944ad6/azure_functions_durable-1.7.0.tar.gz", hash = "sha256:8ba754a44fa57fbb2c314d48fea38a3cf4b43919ff2f6df42ef42b7c8f4a8d52", size = 207994, upload-time = "2026-07-30T22:16:05.769Z" } wheels = [ @@ -1622,11 +1622,11 @@ name = "azure-identity" version = "1.25.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "msal", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "msal-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core" }, + { name = "cryptography" }, + { name = "msal" }, + { name = "msal-extensions" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c5/0e/3a63efb48aa4a5ae2cfca61ee152fbcb668092134d3eb8bfda472dd5c617/azure_identity-1.25.3.tar.gz", hash = "sha256:ab23c0d63015f50b630ef6c6cf395e7262f439ce06e5d07a64e874c724f8d9e6", size = 286304, upload-time = "2026-03-13T01:12:20.892Z" } wheels = [ @@ -1638,19 +1638,19 @@ name = "azure-monitor-opentelemetry" version = "1.8.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-core-tracing-opentelemetry", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-monitor-opentelemetry-exporter", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-django", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-flask", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-logging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-psycopg2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-urllib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-resource-detector-azure", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core" }, + { name = "azure-core-tracing-opentelemetry" }, + { name = "azure-monitor-opentelemetry-exporter" }, + { name = "opentelemetry-instrumentation-django" }, + { name = "opentelemetry-instrumentation-fastapi" }, + { name = "opentelemetry-instrumentation-flask" }, + { name = "opentelemetry-instrumentation-logging" }, + { name = "opentelemetry-instrumentation-psycopg2" }, + { name = "opentelemetry-instrumentation-requests" }, + { name = "opentelemetry-instrumentation-urllib" }, + { name = "opentelemetry-instrumentation-urllib3" }, + { name = "opentelemetry-resource-detector-azure" }, + { name = "opentelemetry-sdk" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9e/9f/75f517dd019ebc1a6476187c7380ea440f040b7c933c7a6b7159260e2c37/azure_monitor_opentelemetry-1.8.9.tar.gz", hash = "sha256:9cda4481c52e02cb3e8e11a34d2491fb07000ed32ee655d75de0747e7d24fea7", size = 80026, upload-time = "2026-07-01T17:48:58.369Z" } wheels = [ @@ -1662,12 +1662,12 @@ name = "azure-monitor-opentelemetry-exporter" version = "1.0.0b56" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "msrest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core" }, + { name = "azure-identity" }, + { name = "msrest" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "psutil" }, ] sdist = { url = "https://files.pythonhosted.org/packages/28/0e/c74f9bf0b071f5e13c93c285d585ddc96de43658076733cab039844c198a/azure_monitor_opentelemetry_exporter-1.0.0b56.tar.gz", hash = "sha256:d645e2dbde68123a1c0ab6a5705424bae5bbda53495f2cd8be1bb2a78e9f31d3", size = 355000, upload-time = "2026-08-05T22:49:43.804Z" } wheels = [ @@ -1679,9 +1679,9 @@ name = "azure-search-documents" version = "12.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/59/dc/bb4db263381aa5b29414e280a8535a343d877a3831a501ef39332174c85c/azure_search_documents-12.0.0.tar.gz", hash = "sha256:8e6d73ec0ed1623083435b757e34324db65d72d4e09cca061a59fc7e90c8ddbc", size = 386222, upload-time = "2026-05-01T20:28:22.269Z" } wheels = [ @@ -1693,10 +1693,10 @@ name = "azure-storage-blob" version = "12.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core" }, + { name = "cryptography" }, + { name = "isodate" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3b/48/84a820d898267f662b5c06f7cd76fdb8a9e272b44aa9376cef3ec0f6a294/azure_storage_blob-12.30.0.tar.gz", hash = "sha256:2cd74d4d5731e5eb6b8d5c5056ee115a5e88f8fdf22517b739836fda685018be", size = 618229, upload-time = "2026-06-08T11:45:35.575Z" } wheels = [ @@ -1717,8 +1717,8 @@ name = "blessed" version = "1.48.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "jinxed", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "wcwidth", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "jinxed" }, + { name = "wcwidth" }, ] sdist = { url = "https://files.pythonhosted.org/packages/23/1b/b7c3971c1b34e6fe32ffcb00be769769cbb8ccaf0678aa9bb6f23aea677a/blessed-1.48.0.tar.gz", hash = "sha256:5ed4c0d40d0121669ef949e4f23465982614eb821bd110d1d5a98ed97dea13d8", size = 14036135, upload-time = "2026-08-07T17:35:44.794Z" } wheels = [ @@ -1739,9 +1739,9 @@ name = "boto3" version = "1.43.67" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "botocore", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "jmespath", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "s3transfer", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ad/bf/ef5de9b55523bc2141d072fbe6614627088e3e6f97b47850216e99de6a1c/boto3-1.43.67.tar.gz", hash = "sha256:75fe983b70d39cfdc274dc51f9bb02b8a0a104bdad4fa073c1af85a35e707c91", size = 112665, upload-time = "2026-08-07T19:30:20.418Z" } wheels = [ @@ -1753,9 +1753,9 @@ name = "botocore" version = "1.43.67" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "jmespath", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, ] sdist = { url = "https://files.pythonhosted.org/packages/53/1c/3a75deae60e36bd0ee5c27d040384756b2ea1c90bd7c8c9658335a18b4f5/botocore-1.43.67.tar.gz", hash = "sha256:6fe5cfa0c8676ba809efe505b618ec00f30d1af2d014bf316a7aa4ee86accb20", size = 15889514, upload-time = "2026-08-07T19:30:15.638Z" } wheels = [ @@ -1881,7 +1881,7 @@ name = "cffi" version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser", marker = "(implementation_name != 'PyPy' and sys_platform == 'darwin') or (implementation_name != 'PyPy' and sys_platform == 'linux') or (implementation_name != 'PyPy' and sys_platform == 'win32')" }, + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ @@ -2025,9 +2025,9 @@ name = "claude-agent-sdk" version = "0.2.132" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "sniffio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "anyio" }, + { name = "mcp", extra = ["ws"] }, + { name = "sniffio" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4d/1d/dbf95d13ff3d7dab474035ba5a58b94bb75d2f9b78eef5b1c578d9da20d3/claude_agent_sdk-0.2.132.tar.gz", hash = "sha256:efc54a6fac96232105a40b6aed870a482dea221151a3b3356c5c514a5844cf7f", size = 312206, upload-time = "2026-08-07T04:14:22.84Z" } wheels = [ @@ -2055,7 +2055,7 @@ name = "clr-loader" version = "0.2.10" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, + { name = "cffi" }, ] sdist = { url = "https://files.pythonhosted.org/packages/18/24/c12faf3f61614b3131b5c98d3bf0d376b49c7feaa73edca559aeb2aee080/clr_loader-0.2.10.tar.gz", hash = "sha256:81f114afbc5005bafc5efe5af1341d400e22137e275b042a8979f3feb9fc9446", size = 83605, upload-time = "2026-01-03T23:13:06.984Z" } wheels = [ @@ -2076,8 +2076,8 @@ name = "contourpy" version = "1.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -2270,7 +2270,7 @@ wheels = [ [package.optional-dependencies] toml = [ - { name = "tomli", marker = "(python_full_version <= '3.11' and sys_platform == 'darwin') or (python_full_version <= '3.11' and sys_platform == 'linux') or (python_full_version <= '3.11' and sys_platform == 'win32')" }, + { name = "tomli", marker = "python_full_version <= '3.11'" }, ] [[package]] @@ -2278,7 +2278,7 @@ name = "croniter" version = "6.2.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "python-dateutil" }, ] sdist = { url = "https://files.pythonhosted.org/packages/37/57/2e2a65aee2a70483cb28e2b7e15a072d00a523207593b44400d4717bb100/croniter-6.2.4.tar.gz", hash = "sha256:fc124f751b1b04805c2a04b061898b436b45ab2320b045e1e052ea895de65189", size = 166267, upload-time = "2026-07-10T09:52:59.955Z" } wheels = [ @@ -2290,7 +2290,7 @@ name = "cryptography" version = "48.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (platform_python_implementation != 'PyPy' and sys_platform == 'win32')" }, + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } wheels = [ @@ -2349,8 +2349,8 @@ name = "culsans" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiologic", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, - { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, + { name = "aiologic" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } wheels = [ @@ -2371,8 +2371,8 @@ name = "deepdiff" version = "9.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cachebox", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "orderly-set", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "cachebox" }, + { name = "orderly-set" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f9/6b/6a4a5aaf38535eb332c2856aa08e73ed7c549d0851b1215401af0a2db1a7/deepdiff-9.1.0.tar.gz", hash = "sha256:07e9e366fab4297755153c4eab795ad4ef3cbd0d51660e847f5751c6bd727687", size = 382149, upload-time = "2026-05-15T20:18:05.751Z" } wheels = [ @@ -2420,10 +2420,10 @@ name = "durabletask" version = "1.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "asyncio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "grpcio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "asyncio" }, + { name = "grpcio" }, + { name = "packaging" }, + { name = "protobuf" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fa/b0/c56f67be79f7d7f3d385ae7d8cf185cb0e928b293c0576933f7d21720695/durabletask-1.9.0.tar.gz", hash = "sha256:55460fbfda8941e721096c4639e72111b03638708df1556af466160ee649478d", size = 184873, upload-time = "2026-07-30T20:56:10.429Z" } wheels = [ @@ -2435,8 +2435,8 @@ name = "durabletask-azuremanaged" version = "1.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "durabletask", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-identity" }, + { name = "durabletask" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f9/ca/871d112dbe94fbcdd78b5e5e64e0ca0e06a58e7de84bf9f883c9a6a68786/durabletask_azuremanaged-1.9.0.tar.gz", hash = "sha256:69cb7df157d9a600228fe85b265f6752c3872f3e5cc358cb13ec1846921ab607", size = 21232, upload-time = "2026-07-30T21:06:33.264Z" } wheels = [ @@ -2448,8 +2448,8 @@ name = "email-validator" version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "dnspython", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "dnspython" }, + { name = "idna" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } wheels = [ @@ -2479,7 +2479,7 @@ name = "expression" version = "5.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/43/c7/bb061623b5815566bda69f5e9d156e38a97ebb383b8db3d2dedb26415466/expression-5.6.0.tar.gz", hash = "sha256:454f6fe138347194a43c7f878d958efe9b84b9cc770e462010c7a52e18058065", size = 59147, upload-time = "2025-02-19T09:37:37.432Z" } wheels = [ @@ -2491,11 +2491,11 @@ name = "fastapi" version = "0.141.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-doc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } wheels = [ @@ -2507,11 +2507,11 @@ name = "fastapi-sso" version = "0.21.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "oauthlib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", extra = ["email"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyjwt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "oauthlib" }, + { name = "pydantic", extra = ["email"] }, + { name = "pyjwt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/41/38/1288b0248f91822bba254ce852466857cb51217b817c3d62bcc21cfd4d9e/fastapi_sso-0.21.1.tar.gz", hash = "sha256:c6f730b075caf537efa7c0f531095cf2d963a6fa27d059b3300e5eb19b282adb", size = 18031, upload-time = "2026-06-22T15:34:56.086Z" } wheels = [ @@ -2593,12 +2593,12 @@ name = "flask" version = "3.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "blinker", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "itsdangerous", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "jinja2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "markupsafe", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "werkzeug", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, ] sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } wheels = [ @@ -2610,11 +2610,11 @@ name = "flit" version = "4.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "docutils", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "flit-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pip", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tomli-w", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "flit-core" }, + { name = "pip" }, + { name = "requests" }, + { name = "tomli-w" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e2/ad/752f9df74127268a587da848d6d561ff56c1508f38ab052caa48bbc130ac/flit-4.0.2.tar.gz", hash = "sha256:232bc4317279e8952eca9fb3f5e000d8395d693b39c105b99f619ce461e1da85", size = 154725, upload-time = "2026-08-04T20:15:40.83Z" } wheels = [ @@ -2684,9 +2684,9 @@ name = "foundry-local-sdk" version = "0.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "tqdm" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/ed/6b/76a7fe8f9f4c52cc84eaa1cd1b66acddf993496d55d6ea587bf0d0854d1c/foundry_local_sdk-0.5.1-py3-none-any.whl", hash = "sha256:f3639a3666bc3a94410004a91671338910ac2e1b8094b1587cc4db0f4a7df07e", size = 14003, upload-time = "2025-11-21T05:39:58.099Z" }, @@ -2802,9 +2802,9 @@ name = "fs" version = "2.4.16" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "appdirs", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "setuptools", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "appdirs" }, + { name = "setuptools" }, + { name = "six" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5d/a9/af5bfd5a92592c16cdae5c04f68187a309be8a146b528eac3c6e30edbad2/fs-2.4.16.tar.gz", hash = "sha256:ae97c7d51213f4b70b6a958292530289090de3a7e15841e108fbe144f069d313", size = 187441, upload-time = "2022-05-02T09:25:54.22Z" } wheels = [ @@ -2825,8 +2825,8 @@ name = "furl" version = "2.1.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "orderedmultidict", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "orderedmultidict" }, + { name = "six" }, ] sdist = { url = "https://files.pythonhosted.org/packages/53/e4/203a76fa2ef46cdb0a618295cc115220cbb874229d4d8721068335eb87f0/furl-2.1.4.tar.gz", hash = "sha256:877657501266c929269739fb5f5980534a41abd6bbabcb367c136d1d3b2a6015", size = 57526, upload-time = "2025-03-09T05:36:21.175Z" } wheels = [ @@ -2838,9 +2838,9 @@ name = "github-copilot-sdk" version = "1.0.11" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "python-dateutil" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/67/ac/175cbb71fe637d963a885248a421040c9d500ea6390ed3b88bc68ecf51ca/github_copilot_sdk-1.0.11-py3-none-any.whl", hash = "sha256:6f664c7b843c34ab5a7455f7effb4d339eae75969d2b72f43c2e6214c9c637dc", size = 486719, upload-time = "2026-08-14T16:12:20.01Z" }, @@ -2851,11 +2851,11 @@ name = "google-api-core" version = "2.34.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "google-auth", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "googleapis-common-protos", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "proto-plus", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7b/7c/9be3903e3d45415e8ca493c75f8990a0f6f579d168015d44c379350d0ab0/google_api_core-2.34.0.tar.gz", hash = "sha256:98a779fe72de956eb1c9c2f47ff4c4432a668ece1a002ec38bed07ec2698ae59", size = 187953, upload-time = "2026-08-06T06:23:58.128Z" } wheels = [ @@ -2867,8 +2867,8 @@ name = "google-auth" version = "2.56.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyasn1-modules", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "cryptography" }, + { name = "pyasn1-modules" }, ] sdist = { url = "https://files.pythonhosted.org/packages/db/4c/fa42116a48bab3f7a143cf5042ecff7df9c8b73f8a376203cd534d1dc966/google_auth-2.56.3.tar.gz", hash = "sha256:40e229fc901f0a305b553050e5fce562d509bee0435be053abfa91582b51b90c", size = 367110, upload-time = "2026-08-06T06:24:01.36Z" } wheels = [ @@ -2877,7 +2877,7 @@ wheels = [ [package.optional-dependencies] requests = [ - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "requests" }, ] [[package]] @@ -2885,16 +2885,16 @@ name = "google-genai" version = "2.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "distro", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "google-auth", extra = ["requests"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "sniffio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tenacity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "websockets", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "anyio" }, + { name = "distro" }, + { name = "google-auth", extra = ["requests"] }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "sniffio" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "websockets" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/2e/03a0486e192cde27f7ffbb20c63fa934a132396a667b1a0e1b7e5f606ad7/google_genai-2.17.0.tar.gz", hash = "sha256:6b640a2390c82b4a240873eddb9f518c6d2c33244b2de16ed3d14526a6da7f57", size = 648676, upload-time = "2026-08-06T05:10:41.382Z" } wheels = [ @@ -2906,7 +2906,7 @@ name = "googleapis-common-protos" version = "1.75.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "protobuf" }, ] sdist = { url = "https://files.pythonhosted.org/packages/72/73/74bcab964c9a7a61f2bb71e8179b0f13e6fa98f7ce00fd168aab291e4a2e/googleapis_common_protos-1.75.1.tar.gz", hash = "sha256:d3042c6c5a2d4e67113104d6b6818b59b6bd92a197f2a91508e801fe815cf071", size = 150967, upload-time = "2026-08-06T06:24:51.972Z" } wheels = [ @@ -2918,9 +2918,9 @@ name = "gpustat" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "blessed", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "nvidia-ml-py", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "blessed" }, + { name = "nvidia-ml-py" }, + { name = "psutil" }, ] sdist = { url = "https://files.pythonhosted.org/packages/79/c4/46d005aec3bf911cb030467d91e062a5386ff4a03e51874424cacc0f60c1/gpustat-1.1.1.tar.gz", hash = "sha256:c18d3ed5518fc16300c42d694debc70aebb3be55cae91f1db64d63b5fa8af9d8", size = 98052, upload-time = "2023-08-22T19:39:06.062Z" } @@ -2929,7 +2929,7 @@ name = "granian" version = "2.8.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "click" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d3/a2/8429f7cd27c99ae954be8663ca46067e0d31ee84fb887a25406a9e5bc8f8/granian-2.8.1.tar.gz", hash = "sha256:41a7954e10621ca46f9bfe829a42272e4796358cc4bf27a9253f3171f871a397", size = 129348, upload-time = "2026-08-05T19:12:17.057Z" } wheels = [ @@ -3099,7 +3099,7 @@ name = "grpcio" version = "1.83.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0c/98/304898ac4e04e2d5e4e4c2eadc178b1f2a16d5f4bc2f91306c87d64680b9/grpcio-1.83.0.tar.gz", hash = "sha256:7674587248fbbb2ac6e4eecf83a8a0f3d91a928f941de571acfd3a2f007fbc24", size = 13428824, upload-time = "2026-07-23T15:20:37.759Z" } wheels = [ @@ -3150,7 +3150,7 @@ name = "gunicorn" version = "23.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "packaging" }, ] sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" } wheels = [ @@ -3171,8 +3171,8 @@ name = "h2" version = "4.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "hpack", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "hyperframe", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "hpack" }, + { name = "hyperframe" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" } wheels = [ @@ -3217,8 +3217,8 @@ name = "httpcore" version = "1.0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "h11", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "certifi" }, + { name = "h11" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ @@ -3273,10 +3273,10 @@ name = "httpx" version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "certifi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpcore", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ @@ -3285,7 +3285,7 @@ wheels = [ [package.optional-dependencies] http2 = [ - { name = "h2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "h2" }, ] [[package]] @@ -3302,15 +3302,15 @@ name = "huggingface-hub" version = "1.27.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "filelock", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "fsspec", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "hf-xet", marker = "(platform_machine == 'AMD64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'darwin') or (platform_machine == 'amd64' and sys_platform == 'darwin') or (platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'AMD64' and sys_platform == 'linux') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'amd64' and sys_platform == 'linux') or (platform_machine == 'arm64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'win32') or (platform_machine == 'amd64' and sys_platform == 'win32') or (platform_machine == 'arm64' and sys_platform == 'win32') or (platform_machine == 'x86_64' and sys_platform == 'win32')" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "click" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3e/9b/ddf3d02a8681f1b9ce52fda03d755dad6b74c4f8172304c4c8d2975450f9/huggingface_hub-1.27.0.tar.gz", hash = "sha256:c1fed40ea82a6b41b477f5243546549b792ae0a93abcea608cff66089bf8f8df", size = 942668, upload-time = "2026-08-07T12:48:05.161Z" } wheels = [ @@ -3322,10 +3322,10 @@ name = "hypercorn" version = "0.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "h2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "priority", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "wsproto", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "h11" }, + { name = "h2" }, + { name = "priority" }, + { name = "wsproto" }, ] sdist = { url = "https://files.pythonhosted.org/packages/44/01/39f41a014b83dd5c795217362f2ca9071cf243e6a75bdcd6cd5b944658cc/hypercorn-0.18.0.tar.gz", hash = "sha256:d63267548939c46b0247dc8e5b45a9947590e35e64ee73a23c074aa3cf88e9da", size = 68420, upload-time = "2025-11-08T13:54:04.78Z" } wheels = [ @@ -3386,7 +3386,7 @@ name = "importlib-metadata" version = "8.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "zipp" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e7/72/c600ae4f68c28fc19f9c31b9403053e5dbb8cace2e6842c7b7c3e4d42fe9/importlib_metadata-8.9.0.tar.gz", hash = "sha256:58850626cef4bd2df100378b0f2aea9724a7b92f10770d547725b047078f99ee", size = 56140, upload-time = "2026-03-20T16:56:26.362Z" } wheels = [ @@ -3407,8 +3407,8 @@ name = "inquirerpy" version = "0.3.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pfzy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "prompt-toolkit", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pfzy" }, + { name = "prompt-toolkit" }, ] sdist = { url = "https://files.pythonhosted.org/packages/64/73/7570847b9da026e07053da3bbe2ac7ea6cde6bb2cbd3c7a5a950fa0ae40b/InquirerPy-0.3.4.tar.gz", hash = "sha256:89d2ada0111f337483cb41ae31073108b2ec1e618a49d7110b0d7ade89fc197e", size = 44431, upload-time = "2022-06-27T23:11:20.598Z" } wheels = [ @@ -3438,7 +3438,7 @@ name = "jinja2" version = "3.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markupsafe", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "markupsafe" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ @@ -3593,10 +3593,10 @@ name = "jsonschema" version = "4.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "attrs", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "jsonschema-specifications", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "referencing", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "rpds-py", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } wheels = [ @@ -3608,7 +3608,7 @@ name = "jsonschema-specifications" version = "2025.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "referencing", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "referencing" }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } wheels = [ @@ -3726,14 +3726,14 @@ name = "langfuse" version = "4.14.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-exporter-otlp-proto-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "backoff" }, + { name = "httpx" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ca/1c/d3410521e8b36d36aa7e6792394d193f91959742292a56f20cdda56f94aa/langfuse-4.14.3.tar.gz", hash = "sha256:eba25be6da7e5eeb640b28d7a0910f5c40cc6449eeaef89ba5d282d9db1a355d", size = 389580, upload-time = "2026-08-06T09:25:51.043Z" } wheels = [ @@ -3861,18 +3861,18 @@ name = "litellm" version = "1.95.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "fastuuid", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "importlib-metadata", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "jinja2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "jsonschema", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tiktoken", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tokenizers", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "aiohttp" }, + { name = "click" }, + { name = "fastuuid" }, + { name = "httpx" }, + { name = "importlib-metadata" }, + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "tiktoken" }, + { name = "tokenizers" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/96/8cdfb9aaf584b57af35a0423c111a1c1264a78b548cebbb5ed96defacdab/litellm-1.95.0.tar.gz", hash = "sha256:0ef126d52c7a559f8353e50d60fd0d5e7e6c8767ad54df25ddaf79b9edca1afc", size = 17513577, upload-time = "2026-08-02T02:52:49.465Z" } wheels = [ @@ -3892,37 +3892,37 @@ wheels = [ [package.optional-dependencies] proxy = [ - { name = "apscheduler", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-storage-blob", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "boto3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "expression", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "fastapi-sso", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "granian", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "gunicorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "inquirerpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "litellm-enterprise", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "litellm-proxy-extras", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "orjson", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "polars", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic-settings", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyjwt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pynacl", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyroscope-io", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "python-multipart", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "restrictedpython", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "rq", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "soundfile", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uvicorn", extra = ["standard"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uvloop", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "websockets", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "apscheduler" }, + { name = "azure-identity" }, + { name = "azure-storage-blob" }, + { name = "backoff" }, + { name = "boto3" }, + { name = "cryptography" }, + { name = "expression" }, + { name = "fastapi" }, + { name = "fastapi-sso" }, + { name = "granian" }, + { name = "gunicorn" }, + { name = "inquirerpy" }, + { name = "litellm-enterprise" }, + { name = "litellm-proxy-extras" }, + { name = "mcp", extra = ["ws"] }, + { name = "orjson" }, + { name = "polars" }, + { name = "pydantic-settings" }, + { name = "pyjwt" }, + { name = "pynacl" }, + { name = "pyroscope-io", marker = "sys_platform != 'win32'" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "restrictedpython" }, + { name = "rich" }, + { name = "rq" }, + { name = "soundfile" }, + { name = "starlette" }, + { name = "uvicorn", extra = ["standard"] }, + { name = "uvloop", marker = "sys_platform != 'win32'" }, + { name = "websockets" }, ] [[package]] @@ -3961,7 +3961,7 @@ name = "markdown-it-py" version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mdurl", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "mdurl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } wheels = [ @@ -4047,16 +4047,16 @@ name = "matplotlib" version = "3.11.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "contourpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "cycler", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "fonttools", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "kiwisolver", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pillow", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyparsing", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, ] sdist = { url = "https://files.pythonhosted.org/packages/49/64/f9a391af28f518b11ad45a8a712353c94a0aefce09d3703200e5c54b610a/matplotlib-3.11.1.tar.gz", hash = "sha256:69647db5746941c793d6e445a4cd349323ffb87d9cc958c2ad84a659b4832d30", size = 32612045, upload-time = "2026-07-18T03:39:46.63Z" } wheels = [ @@ -4112,20 +4112,20 @@ name = "mcp" version = "1.29.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx-sse", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "jsonschema", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic-settings", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyjwt", extra = ["crypto"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-multipart", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, - { name = "sse-starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uvicorn", extra = ["standard"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", extra = ["standard"] }, ] sdist = { url = "https://files.pythonhosted.org/packages/30/d3/f9acc21dfc886e4f78e2add1a47db46ce16884346afde53f8a064c02c891/mcp-1.29.0.tar.gz", hash = "sha256:52d01f334de1868cc3bb2d6604931126a67631f99a6c5d3b82ba47290315ec36", size = 643148, upload-time = "2026-07-28T13:41:41.939Z" } wheels = [ @@ -4134,7 +4134,7 @@ wheels = [ [package.optional-dependencies] ws = [ - { name = "websockets", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "websockets" }, ] [[package]] @@ -4151,14 +4151,14 @@ name = "mem0ai" version = "2.0.17" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "posthog", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytz", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "qdrant-client", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "sqlalchemy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx" }, + { name = "openai" }, + { name = "posthog" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "pytz" }, + { name = "qdrant-client" }, + { name = "sqlalchemy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/87/42/4e266867444612ec4069bf57cd7c5e7ffbd53b4ee8216551d8a89d978c7f/mem0ai-2.0.17.tar.gz", hash = "sha256:fc84dcf12b656d63d662d57353d137ddccfcf67665cbe2476793a8115930c262", size = 248132, upload-time = "2026-08-05T16:43:37.648Z" } wheels = [ @@ -4170,7 +4170,7 @@ name = "microsoft-agents-activity" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/37/c6d132590089b52508c17768bfc63fc049380b8735b639eb3f6cfe52b729/microsoft_agents_activity-1.3.0.tar.gz", hash = "sha256:b62f51710343548ca90d5b192ba9a27a31fc9868ed1ccd79a09d5442e74e8690", size = 71931, upload-time = "2026-07-30T23:06:35.25Z" } wheels = [ @@ -4182,7 +4182,7 @@ name = "microsoft-agents-copilotstudio-client" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "microsoft-agents-hosting-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "microsoft-agents-hosting-core" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bf/fa/a93b0b221a3cc5d398e2867e353003297e27cb9a42ec63da4983355fe0aa/microsoft_agents_copilotstudio_client-1.3.0.tar.gz", hash = "sha256:377b02ea050c4b1e56d3c89ffe08e5bb5af0158268733ab42d57c3619f06990d", size = 28503, upload-time = "2026-07-30T23:06:38.814Z" } wheels = [ @@ -4194,12 +4194,12 @@ name = "microsoft-agents-hosting-core" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "microsoft-agents-activity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyjwt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "isodate" }, + { name = "microsoft-agents-activity" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "pyjwt" }, + { name = "python-dotenv" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ba/ab/6f937cdbf7fdcfb6f38008a753613b992db44deeb8c129bffd48537d06f6/microsoft_agents_hosting_core-1.3.0.tar.gz", hash = "sha256:1be7c5c9a7233ebc2a0a2d59c409dd39bc223eea89515e870ec1517bd3c2d815", size = 140706, upload-time = "2026-07-30T23:06:40.852Z" } wheels = [ @@ -4211,30 +4211,30 @@ name = "microsoft-opentelemetry" version = "1.3.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-core-tracing-opentelemetry", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "azure-monitor-opentelemetry-exporter", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-exporter-otlp-proto-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-django", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-flask", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-logging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-openai-agents-v2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-openai-v2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-psycopg2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-urllib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-resource-detector-azure", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-util-genai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyjwt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "aiohttp" }, + { name = "azure-core" }, + { name = "azure-core-tracing-opentelemetry" }, + { name = "azure-monitor-opentelemetry-exporter" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-django" }, + { name = "opentelemetry-instrumentation-fastapi" }, + { name = "opentelemetry-instrumentation-flask" }, + { name = "opentelemetry-instrumentation-httpx" }, + { name = "opentelemetry-instrumentation-logging" }, + { name = "opentelemetry-instrumentation-openai-agents-v2" }, + { name = "opentelemetry-instrumentation-openai-v2" }, + { name = "opentelemetry-instrumentation-psycopg2" }, + { name = "opentelemetry-instrumentation-requests" }, + { name = "opentelemetry-instrumentation-urllib" }, + { name = "opentelemetry-instrumentation-urllib3" }, + { name = "opentelemetry-resource-detector-azure" }, + { name = "opentelemetry-sdk" }, + { name = "opentelemetry-util-genai" }, + { name = "pyjwt" }, + { name = "requests" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/1d/51653359fe9418ba9a4c731945b3d0b5ab587e605aa16392eb0d1ca3e145/microsoft_opentelemetry-1.3.7.tar.gz", hash = "sha256:ec4395d818c9716599d13b32169e95e03021559627f28a3ded688e39d65a5245", size = 190153, upload-time = "2026-08-05T15:38:55.359Z" } wheels = [ @@ -4246,14 +4246,14 @@ name = "mistralai" version = "2.9.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "eval-type-backport", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "jsonpath-python", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "eval-type-backport" }, + { name = "httpx" }, + { name = "jsonpath-python" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "typing-inspection" }, ] sdist = { url = "https://files.pythonhosted.org/packages/34/52/51065b4b453b0cd410fbc9d659d9b43345d4cef74f610cf4cad3c2682e22/mistralai-2.9.4.tar.gz", hash = "sha256:e3607552d34cc38b6f81e80bf95201946eac119260259b0cc1094aac350dbb8e", size = 543545, upload-time = "2026-08-21T18:06:03.045Z" } wheels = [ @@ -4265,8 +4265,8 @@ name = "ml-dtypes" version = "0.5.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/4a/c27b42ed9b1c7d13d9ba8b6905dece787d6259152f2309338aed29b2447b/ml_dtypes-0.5.4.tar.gz", hash = "sha256:8ab06a50fb9bf9666dd0fe5dfb4676fa2b0ac0f31ecff72a6c3af8e22c063453", size = 692314, upload-time = "2025-11-17T22:32:31.031Z" } wheels = [ @@ -4316,9 +4316,9 @@ name = "msal" version = "1.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyjwt", extra = ["crypto"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "cryptography" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9a/99/d840198ecf6e8057bbc937f129ae940404485d736cda73253bbff9537f01/msal-1.37.0.tar.gz", hash = "sha256:1b1672a33ee467c1d70b341bb16cafd51bb3c817147a95b93263794b03971bec", size = 182444, upload-time = "2026-05-29T19:49:05.561Z" } wheels = [ @@ -4330,7 +4330,7 @@ name = "msal-extensions" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "msal", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "msal" }, ] sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315, upload-time = "2025-03-14T23:51:03.902Z" } wheels = [ @@ -4390,11 +4390,11 @@ name = "msrest" version = "0.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "certifi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests-oauthlib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core" }, + { name = "certifi" }, + { name = "isodate" }, + { name = "requests" }, + { name = "requests-oauthlib" }, ] sdist = { url = "https://files.pythonhosted.org/packages/68/77/8397c8fb8fc257d8ea0fa66f8068e073278c65f05acb17dcb22a02bfdc42/msrest-0.7.1.zip", hash = "sha256:6e7661f46f3afd88b75667b7187a92829924446c7ea1d169be8c4bb7eeb788b9", size = 175332, upload-time = "2022-06-13T22:41:25.111Z" } wheels = [ @@ -4523,11 +4523,11 @@ name = "mypy" version = "2.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ast-serialize", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "librt", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (platform_python_implementation != 'PyPy' and sys_platform == 'win32')" }, - { name = "mypy-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pathspec", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } wheels = [ @@ -4777,8 +4777,8 @@ name = "ollama" version = "0.5.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx" }, + { name = "pydantic" }, ] sdist = { url = "https://files.pythonhosted.org/packages/91/6d/ae96027416dcc2e98c944c050c492789502d7d7c0b95a740f0bb39268632/ollama-0.5.3.tar.gz", hash = "sha256:40b6dff729df3b24e56d4042fd9d37e231cee8e528677e0d085413a1d6692394", size = 43331, upload-time = "2025-08-07T21:44:10.422Z" } wheels = [ @@ -4790,14 +4790,14 @@ name = "openai" version = "2.53.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "distro", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "jiter", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "sniffio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/cf/36e3e7235fdf6d125c052acc0970924611b17a20a4fe580596faf4566a65/openai-2.53.0.tar.gz", hash = "sha256:baf5802ad08980e1d9d561e1b996e800c8bcd14af5847c6d0e7a5cc59e4d4116", size = 1099435, upload-time = "2026-08-03T21:42:01.664Z" } wheels = [ @@ -4809,13 +4809,13 @@ name = "openai-agents" version = "0.19.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "griffelib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "websockets", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "griffelib" }, + { name = "mcp", extra = ["ws"] }, + { name = "openai" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "typing-extensions" }, + { name = "websockets" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ff/ea/a8cae2dadf798f369be5f9cb544a169f5f6aecc096a80f1a209dddc4c00f/openai_agents-0.19.4.tar.gz", hash = "sha256:fe21778ee1e8216c9cdb775fa86d11b08be68c0184e14023993088d3f812c0be", size = 5784063, upload-time = "2026-08-05T02:59:12.939Z" } wheels = [ @@ -4827,11 +4827,11 @@ name = "openai-chatkit" version = "1.6.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "jinja2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "openai-agents", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uvicorn", extra = ["standard"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "jinja2" }, + { name = "openai" }, + { name = "openai-agents" }, + { name = "pydantic" }, + { name = "uvicorn", extra = ["standard"] }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/07/c4b4ea034f34f25e73cf1a872deb349e3acab6c929a5531d547a9a994890/openai_chatkit-1.6.5.tar.gz", hash = "sha256:903e9702bf26cd8a2b23d4e7b199b657bee4379758e0ca11ebaee09362d2889e", size = 65057, upload-time = "2026-05-19T05:05:14.954Z" } wheels = [ @@ -4843,7 +4843,7 @@ name = "opentelemetry-api" version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ae/cc/e4c9584181f86494df0f6bdec1a4f3280c50db44704dc2a407e994fc87bb/opentelemetry_api-1.43.0.tar.gz", hash = "sha256:107d0d03857ea8fc7c5fcbbbd83f800c281f0d560553d61c1d675fccfd1761c1", size = 73476, upload-time = "2026-06-24T15:19:55.323Z" } wheels = [ @@ -4855,8 +4855,8 @@ name = "opentelemetry-exporter-otlp" version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-exporter-otlp-proto-grpc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-exporter-otlp-proto-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ee/47/b77366bcbe719373a8cac2b4e8ad01f8ff3c9f2c223374d77ece280aae6f/opentelemetry_exporter_otlp-1.43.0.tar.gz", hash = "sha256:65aded6c50ee7dd2b9948c9d0e59ddb4ed4eea6e8532fba95cbe6a4a64a566ba", size = 6086, upload-time = "2026-06-24T15:19:58.003Z" } wheels = [ @@ -4868,7 +4868,7 @@ name = "opentelemetry-exporter-otlp-proto-common" version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-proto", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-proto" }, ] sdist = { url = "https://files.pythonhosted.org/packages/55/c1/e8098490ab15abf116dcaf9fa89ededcb35547c7d08d4b5a62f573dc1e63/opentelemetry_exporter_otlp_proto_common-1.43.0.tar.gz", hash = "sha256:c4e32ba6d6b13bdb2b8f6764c4fd28d00192826561aa04f6d14eedfce7ac076f", size = 20197, upload-time = "2026-06-24T15:20:00.247Z" } wheels = [ @@ -4880,13 +4880,13 @@ name = "opentelemetry-exporter-otlp-proto-grpc" version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "googleapis-common-protos", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "grpcio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-exporter-otlp-proto-common", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-proto", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e2/1d/6336453716ca0a240d4417d19e6d5b77a5e7163e5670ec4f7ec4d3ede7bf/opentelemetry_exporter_otlp_proto_grpc-1.43.0.tar.gz", hash = "sha256:1b3e0627daa9bc21884d4a13946807c255eb558bfe5bdd543dffb6f4c9faee0d", size = 27213, upload-time = "2026-06-24T15:20:00.907Z" } wheels = [ @@ -4898,13 +4898,13 @@ name = "opentelemetry-exporter-otlp-proto-http" version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "googleapis-common-protos", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-exporter-otlp-proto-common", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-proto", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fc/92/0b9f56412483a8891d4843890294796c9df8ab42417bd9bad8035d840cb3/opentelemetry_exporter_otlp_proto_http-1.43.0.tar.gz", hash = "sha256:fa8a42bb7d00ee5391f4c0b04d8e6a46c03caa437903296ab73a81dc11ba118f", size = 25406, upload-time = "2026-06-24T15:20:01.515Z" } wheels = [ @@ -4916,10 +4916,10 @@ name = "opentelemetry-instrumentation" version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7e/97/02fe6e1c8b1ffac42d0b429c18080edb24e0e0d18c86612edf72b5752382/opentelemetry_instrumentation-0.64b0.tar.gz", hash = "sha256:b47d528dead6271d7743114417eb67fc915bd9258111c48dbf9a4951d2efa88d", size = 41935, upload-time = "2026-06-24T15:19:12.951Z" } wheels = [ @@ -4931,11 +4931,11 @@ name = "opentelemetry-instrumentation-asgi" version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "asgiref", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "asgiref" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, ] sdist = { url = "https://files.pythonhosted.org/packages/85/0c/71c696fccb86d37af383ea1604af4729fabad0af2fabaf203e4c79c1e859/opentelemetry_instrumentation_asgi-0.64b0.tar.gz", hash = "sha256:4dd3eee566a4303f8e6b9b84f2a0a7abc57a6640df768926c68a3868bf5b2090", size = 26136, upload-time = "2026-06-24T15:19:17.003Z" } wheels = [ @@ -4947,10 +4947,10 @@ name = "opentelemetry-instrumentation-dbapi" version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/9f/3c6ce6f4394faa1540f48b7d442f455582ca76449952506ce767a54f09bf/opentelemetry_instrumentation_dbapi-0.64b0.tar.gz", hash = "sha256:8e3a1528fc9753a04190a7e7bf0180c5abd059f3aa68ec3857edb363c9847c44", size = 20138, upload-time = "2026-06-24T15:19:24.097Z" } wheels = [ @@ -4962,11 +4962,11 @@ name = "opentelemetry-instrumentation-django" version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-wsgi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-wsgi" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ce/2e/8f6084eab186533b0574683a8c39e51803e6c34c21fc33e194f58352d704/opentelemetry_instrumentation_django-0.64b0.tar.gz", hash = "sha256:3d2673b4f77156b15b1b119c8849b50e3644cfc325f7a24c03602596de2dbba4", size = 25387, upload-time = "2026-06-24T15:19:24.795Z" } wheels = [ @@ -4978,11 +4978,11 @@ name = "opentelemetry-instrumentation-fastapi" version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-asgi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-asgi" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, ] sdist = { url = "https://files.pythonhosted.org/packages/db/a1/52282e2cc5c08f4df13b087896d9907258fe2ff4f34035d3b21aa92684c3/opentelemetry_instrumentation_fastapi-0.64b0.tar.gz", hash = "sha256:05f75149929e433c1630de381688e650bf651c1e1cce7f9a7b649a807dac8a98", size = 26236, upload-time = "2026-06-24T15:19:27.219Z" } wheels = [ @@ -4994,12 +4994,12 @@ name = "opentelemetry-instrumentation-flask" version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-wsgi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-wsgi" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, + { name = "packaging" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4a/6d/cf9a9cf18603e3aaebba96633ec3c1ced463706f545d8672419b715c1e59/opentelemetry_instrumentation_flask-0.64b0.tar.gz", hash = "sha256:74d07edba426beae1214bd0aec69bd31a9d0d22c29747b497d5d94c516d1c860", size = 24150, upload-time = "2026-06-24T15:19:27.847Z" } wheels = [ @@ -5011,11 +5011,11 @@ name = "opentelemetry-instrumentation-httpx" version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0d/2a/2893a8781b93894f1e8014904c0342da7e4302de6597c2d5c0cb6c1a552e/opentelemetry_instrumentation_httpx-0.64b0.tar.gz", hash = "sha256:c2cfcd03d3665762860ebd0c28038c6e47fbb48d7942dec31dd75fc634d25c92", size = 23555, upload-time = "2026-06-24T15:19:29.107Z" } wheels = [ @@ -5027,9 +5027,9 @@ name = "opentelemetry-instrumentation-logging" version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/88/8e/3b7191ba5817f139867612e960fbe06a69fe0d522d3b2c5e9c8e89b3ef8c/opentelemetry_instrumentation_logging-0.64b0.tar.gz", hash = "sha256:6435b4d215bf8183e15f7e3416a10ebe1c411810d3411fbfc62667daae3abacb", size = 20000, upload-time = "2026-06-24T15:19:30.946Z" } wheels = [ @@ -5041,10 +5041,10 @@ name = "opentelemetry-instrumentation-openai-agents-v2" version = "0.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-util-genai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-genai" }, ] sdist = { url = "https://files.pythonhosted.org/packages/00/15/b6a303454d2800d772cdebc490c1d598d06d0e541619db80195eb9ea85c6/opentelemetry_instrumentation_openai_agents_v2-0.1.0.tar.gz", hash = "sha256:1033f4b261ce07f65d197ac0e9c499302c805eae987a6cc4e7f99bb279363477", size = 22423, upload-time = "2025-10-15T19:04:59.912Z" } wheels = [ @@ -5056,9 +5056,9 @@ name = "opentelemetry-instrumentation-openai-v2" version = "2.3b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/4e/21f8cd16ccb471dd217ed85eb817796a10c4f2718ae2c91e752a57180cf0/opentelemetry_instrumentation_openai_v2-2.3b0.tar.gz", hash = "sha256:5de9d70cc9536eea1fe48ea016e0c5f25735fa9a13709076a64b20657fadb6ba", size = 170838, upload-time = "2025-12-24T13:20:58.33Z" } wheels = [ @@ -5070,9 +5070,9 @@ name = "opentelemetry-instrumentation-psycopg2" version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation-dbapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-dbapi" }, ] sdist = { url = "https://files.pythonhosted.org/packages/79/2d/0062ecf86b6bd407398820ae34d8acf7259f2b32531fa22e1c3b7748c6d0/opentelemetry_instrumentation_psycopg2-0.64b0.tar.gz", hash = "sha256:57df449718297b93e7bb57c76d3439c475eb5a5451600fcd6a9024d74822d972", size = 12065, upload-time = "2026-06-24T15:19:33.994Z" } wheels = [ @@ -5084,10 +5084,10 @@ name = "opentelemetry-instrumentation-requests" version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a1/97/d5fb7b3f329c24ff2f6478f1a0c0c516ba942d3df2acbb197d276af31816/opentelemetry_instrumentation_requests-0.64b0.tar.gz", hash = "sha256:8213a20b6578c41aa05cc5b48419aea75e1584924a4904dfcf36524597e7cc96", size = 18106, upload-time = "2026-06-24T15:19:39.522Z" } wheels = [ @@ -5099,10 +5099,10 @@ name = "opentelemetry-instrumentation-urllib" version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, ] sdist = { url = "https://files.pythonhosted.org/packages/87/20/547126ab1706e65629c2079cdbb956ef06ccc58a7cbad520c3505886e4cc/opentelemetry_instrumentation_urllib-0.64b0.tar.gz", hash = "sha256:954ab9908f08dc9127ca3d259f88a37b52f54c03f6fa4c384fd9fcfe9c85cd76", size = 16668, upload-time = "2026-06-24T15:19:45.096Z" } wheels = [ @@ -5114,11 +5114,11 @@ name = "opentelemetry-instrumentation-urllib3" version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/30/e2/9e6f747018f639b9ffed97f715f1eb16c95dd87ba572c880ce451a65a18f/opentelemetry_instrumentation_urllib3-0.64b0.tar.gz", hash = "sha256:647c6d1b012e14168323b18d4e60fca9a5d280764821b99ea0a5639450fbd74e", size = 18945, upload-time = "2026-06-24T15:19:45.738Z" } wheels = [ @@ -5130,10 +5130,10 @@ name = "opentelemetry-instrumentation-wsgi" version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, ] sdist = { url = "https://files.pythonhosted.org/packages/95/4f/0b5232a0d635eee5da7c85af503d2f00fdcac61cc6d4d4a69aadf1fa7a0e/opentelemetry_instrumentation_wsgi-0.64b0.tar.gz", hash = "sha256:1cce6ea28d1800c154e6ccdf52f05bae2f05c5e28ee44f0dddb17ada26208986", size = 19667, upload-time = "2026-06-24T15:19:46.407Z" } wheels = [ @@ -5145,7 +5145,7 @@ name = "opentelemetry-proto" version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "protobuf" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e0/b9/d357faefb40bda1d4799913e6af611171ff22a2dedcb93576bc92242d056/opentelemetry_proto-1.43.0.tar.gz", hash = "sha256:224778df17e1f3fafeaaa21d874236ca5f6ffc2f86e0899298ec7351aac27924", size = 46481, upload-time = "2026-06-24T15:20:07.625Z" } wheels = [ @@ -5157,7 +5157,7 @@ name = "opentelemetry-resource-detector-azure" version = "0.1.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-sdk" }, ] sdist = { url = "https://files.pythonhosted.org/packages/67/e4/0d359d48d03d447225b30c3dd889d5d454e3b413763ff721f9b0e4ac2e59/opentelemetry_resource_detector_azure-0.1.5.tar.gz", hash = "sha256:e0ba658a87c69eebc806e75398cd0e9f68a8898ea62de99bc1b7083136403710", size = 11503, upload-time = "2024-05-16T21:54:58.994Z" } wheels = [ @@ -5169,9 +5169,9 @@ name = "opentelemetry-sdk" version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3e/eb/5041074274ac0956b03637cc039d434569112468e875eddfcc9a0674ce06/opentelemetry_sdk-1.43.0.tar.gz", hash = "sha256:d8187c81c162df9913e4003dd6485f7390d9a24fc17026ec7387b8b8218b08e9", size = 254744, upload-time = "2026-06-24T15:20:08.467Z" } wheels = [ @@ -5183,8 +5183,8 @@ name = "opentelemetry-semantic-conventions" version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5a/30/5f26df29509eccd86b99b481ac9ffa39da49ba9577cc69071c552ae30447/opentelemetry_semantic_conventions-0.64b0.tar.gz", hash = "sha256:72f76fb2d1582d9d033dd1fcd84532e961e6ff3d90d24ba6fabc72975a83864c", size = 148340, upload-time = "2026-06-24T15:20:09.267Z" } wheels = [ @@ -5196,9 +5196,9 @@ name = "opentelemetry-util-genai" version = "0.3b0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a2/d8/4dd2fb622d26ec45b10ef63eb87fd512f5d7467c7bd35ce390629bd6dff8/opentelemetry_util_genai-0.3b0.tar.gz", hash = "sha256:83e127789a9ad615b8ca65f05fc36955a67ce257b06142bfd46159a3b7ed73d3", size = 31800, upload-time = "2026-02-20T16:16:14.807Z" } wheels = [ @@ -5228,7 +5228,7 @@ name = "orderedmultidict" version = "1.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "six" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5c/62/61ad51f6c19d495970230a7747147ce7ed3c3a63c2af4ebfdb1f6d738703/orderedmultidict-1.0.2.tar.gz", hash = "sha256:16a7ae8432e02cc987d2d6d5af2df5938258f87c870675c73ee77a0920e6f4a6", size = 13973, upload-time = "2025-11-18T08:00:42.649Z" } wheels = [ @@ -5326,9 +5326,9 @@ name = "pandas" version = "3.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, - { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "python-dateutil" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } @@ -5502,8 +5502,8 @@ name = "plotly" version = "6.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "narwhals", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "narwhals" }, + { name = "packaging" }, ] sdist = { url = "https://files.pythonhosted.org/packages/96/07/795c79dbce40c39bece88e69d049babbd23ffa95b5d117f248db8ea03abb/plotly-6.9.0.tar.gz", hash = "sha256:967ad33e8c704fed051800d11d985eb206a9c795c14206b30a6f463ed9c67d0d", size = 6919903, upload-time = "2026-07-09T14:55:59.982Z" } wheels = [ @@ -5524,8 +5524,8 @@ name = "poethepoet" version = "0.48.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pastel", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pastel" }, + { name = "pyyaml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5c/92/93a4af9511b8c7c647874521d9e6c904266be98067c2ee1eb2e74520d208/poethepoet-0.48.0.tar.gz", hash = "sha256:a06f49d244fadfc2e2e7faa78b54e64a9694727e4ce1d50e08f23cea3ded74f1", size = 148679, upload-time = "2026-07-05T21:48:30.106Z" } wheels = [ @@ -5537,7 +5537,7 @@ name = "polars" version = "1.43.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "polars-runtime-32", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "polars-runtime-32" }, ] sdist = { url = "https://files.pythonhosted.org/packages/89/13/3873f213304bcbaaf39e63c8b905ceb460a0524448d57f86a829f6d4d0fd/polars-1.43.2.tar.gz", hash = "sha256:c699671b99eb71ff53334d237917aaa3db5ad4dda480abcb6c80e0eaee7b677b", size = 750312, upload-time = "2026-08-01T06:28:30.872Z" } wheels = [ @@ -5577,7 +5577,7 @@ name = "portpicker" version = "1.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "psutil" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4d/d0/cda2fc582f09510c84cd6b7d7b9e22a02d4e45dbad2b2ef1c6edd7847e00/portpicker-1.6.0.tar.gz", hash = "sha256:bd507fd6f96f65ee02781f2e674e9dc6c99bbfa6e3c39992e3916204c9d431fa", size = 25676, upload-time = "2023-08-15T04:37:08.865Z" } wheels = [ @@ -5589,10 +5589,10 @@ name = "posthog" version = "7.38.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "distro", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "backoff" }, + { name = "distro" }, + { name = "requests" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5f/4f/edfbe2aac190ade695693a452bb6d979e8ca5bc54236c28cb796a43ce79d/posthog-7.38.3.tar.gz", hash = "sha256:2a21f13eb48985be58d9533221ec032f579a0f5becf14264a3ea277888af21d1", size = 422163, upload-time = "2026-08-07T18:05:13.739Z" } wheels = [ @@ -5604,8 +5604,8 @@ name = "powerfx" version = "0.0.34" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, - { name = "pythonnet", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, + { name = "cffi" }, + { name = "pythonnet" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/fb/6c4bf87e0c74ca1c563921ce89ca1c5785b7576bca932f7255cdf81082a7/powerfx-0.0.34.tar.gz", hash = "sha256:956992e7afd272657ed16d80f4cad24ec95d9e4a79fb9dfa4a068a09e136af32", size = 3237555, upload-time = "2025-12-22T15:50:59.682Z" } wheels = [ @@ -5614,26 +5614,26 @@ wheels = [ [[package]] name = "prek" -version = "0.4.11" +version = "0.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c4/1a/73b6dae5ce7e997cb35a69bfe1d25a798e85fa3d2eabf95324563f461b30/prek-0.4.11.tar.gz", hash = "sha256:4a14cb9bbae850605ae3904fbdbb12f0e00c12455efaa2266da8fb8e5c0350d7", size = 516254, upload-time = "2026-07-24T17:05:35.107Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/c8/ad3efcaf7007e4796f9a7482b4a3d84402c1535109695cba76fcb87cb03d/prek-0.5.0.tar.gz", hash = "sha256:8df015db60c1e9a30b4a266e65fa14c4d41d2b2b3b90879d76a5d8970dcce64d", size = 544033, upload-time = "2026-08-27T03:50:33.079Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/d7/a00b2de492a80e99b1698e72c1d196ac3ed544dc7ee0ada261ac066e78e0/prek-0.4.11-py3-none-linux_armv6l.whl", hash = "sha256:3830cb7cc47e837888b8b464ecb21355a69235cfacef0fd89101e17f09345d63", size = 5770511, upload-time = "2026-07-24T17:05:12.829Z" }, - { url = "https://files.pythonhosted.org/packages/c1/1e/f97c74defcd5d5645888cb99fcdd9b8b48cc7247cf31e64414d106d56d66/prek-0.4.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:45facadf9c2332b28e6ab2744312ae0275a93ab5a437da8bcc53a8c7260cb4b0", size = 6118049, upload-time = "2026-07-24T17:05:14.451Z" }, - { url = "https://files.pythonhosted.org/packages/d9/92/8367d26421ee6fe6019a63928fb0ed31179cd0d6199879c524f89ef4c95c/prek-0.4.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2f8a194b1d00d24dff8baff691c99e2668339da2da3482b4ee99c1a0f2409378", size = 5601478, upload-time = "2026-07-24T17:05:15.81Z" }, - { url = "https://files.pythonhosted.org/packages/e1/6f/7617c9b87afaadede4167720aae7ef12d7e51167db903bc8fbac0cadef7b/prek-0.4.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:21d93e6d76bf3d7a9bb70c6ac86ef372adaee50068c45749e6b9e1c2ac4ec939", size = 5932071, upload-time = "2026-07-24T17:05:17.217Z" }, - { url = "https://files.pythonhosted.org/packages/ef/41/6796a4011b04212333259064aa885a71d03d9581ba9bff52db3ce58d1f06/prek-0.4.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7fb07cde2d2156efa6980122b3f13dc88f20c84b933c6205675d0f1e7be2cde8", size = 5677617, upload-time = "2026-07-24T17:05:18.658Z" }, - { url = "https://files.pythonhosted.org/packages/03/5f/3e339901f8460b6073313619b4fd9bf4135e48430695c53640b83ace88ea/prek-0.4.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6b7f5e446d2aca739bd18b380578e9c78bb4c086299abc3e167d51c47840c56f", size = 6106370, upload-time = "2026-07-24T17:05:20.219Z" }, - { url = "https://files.pythonhosted.org/packages/8e/4e/94e24b5c1910ec15692ecaf33d0d8ef0d02a02a8b5e40d3c976396880cba/prek-0.4.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7059d640e595d098600e2d97af961f46292b4112670edbe632de84f6828389e", size = 6884342, upload-time = "2026-07-24T17:05:21.587Z" }, - { url = "https://files.pythonhosted.org/packages/a2/7c/fc0daa033dcafe74990c00af2da1e16922790b9c1b8da7e8eebf1123838b/prek-0.4.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85a4df33998fcac878bce3b2c624e1caeb9609f3d562c640702c1234ed815daf", size = 6331365, upload-time = "2026-07-24T17:05:22.87Z" }, - { url = "https://files.pythonhosted.org/packages/d7/36/49f152b8f539930e9685cff0509695ce4054abcecc22f4c16c4e1e5c23d0/prek-0.4.11-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:866991f387527c5f880ce2cc3ddea9b33cc5b986881f8c7f92524cf0969c1350", size = 5939075, upload-time = "2026-07-24T17:05:24.249Z" }, - { url = "https://files.pythonhosted.org/packages/4f/fe/7b097af9161edae7ddedcc9f5cda4a5f31346a492ce3f92583d96c46f628/prek-0.4.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:247e5d8740e137ebdf24fa96f01a95b2d2f1892e956a1ab00c7b1474a59ebcc0", size = 5799029, upload-time = "2026-07-24T17:05:25.652Z" }, - { url = "https://files.pythonhosted.org/packages/49/63/dc955ff99e1002d3cd375b21467c87046bc41deddfce86d898240757b151/prek-0.4.11-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:603ba9f2fd9d666dddb3ae190a25a5c55091b843cde90ed52e0a1116f50d4062", size = 5651211, upload-time = "2026-07-24T17:05:27.027Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b8/bf2139ec25eefb5afef43afac7620c5181f2c2661f220598beacb1771207/prek-0.4.11-py3-none-musllinux_1_1_i686.whl", hash = "sha256:f2682ece3c5fc7201106c4fdd84b0587ccea4b8a2ecefa3e94d3074d2841f1df", size = 5954784, upload-time = "2026-07-24T17:05:28.391Z" }, - { url = "https://files.pythonhosted.org/packages/fc/29/3fe5990aee1bd7c4d50a03358ad867c5e912d9510aafb83a359c7347e5fa/prek-0.4.11-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:22721d30394192931fecc80d6fc6dd47e8ddf8db8b9693805aba8ec0f50087ec", size = 6448916, upload-time = "2026-07-24T17:05:29.837Z" }, - { url = "https://files.pythonhosted.org/packages/0f/fb/abddacf43738302242ecd237236c7c80cd7c1d27545e16e803770aee76e9/prek-0.4.11-py3-none-win32.whl", hash = "sha256:8b093e7624522146049e994d5cf283d01d71632b638620b79ae0f96afeaa2624", size = 5483539, upload-time = "2026-07-24T17:05:31.228Z" }, - { url = "https://files.pythonhosted.org/packages/00/1e/c293f7a15cb93963c4be36a02e144b93f43906bc02644a3f04e5708e7453/prek-0.4.11-py3-none-win_amd64.whl", hash = "sha256:5a3d7c80b970b456e5f1bcec8382008ee1ae6a3f324a6b9bb4ff7e666ab0f3c4", size = 5861119, upload-time = "2026-07-24T17:05:32.479Z" }, - { url = "https://files.pythonhosted.org/packages/cd/0c/05fe6eb9d6a54d0e02dfa8cc5ad6f86869bf953419ec15909a32466a28ee/prek-0.4.11-py3-none-win_arm64.whl", hash = "sha256:e7b0df37ce05e45a14a9da39ab104691474d72f139bf4f6c860f754763a322cb", size = 5626386, upload-time = "2026-07-24T17:05:33.813Z" }, + { url = "https://files.pythonhosted.org/packages/89/83/a5b36db04d5ed8c461b9fec216193a52eee5be37170a9387022f832312b6/prek-0.5.0-py3-none-linux_armv6l.whl", hash = "sha256:df794a883e347ef78cb74522a143dbd8cc2a66e765ef328f48b615f37098015e", size = 5593469, upload-time = "2026-08-27T03:50:00.402Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b5/cd6cab203693a1c45d5a3bae3b8eb56174d595da8af6d6e1595c795f909a/prek-0.5.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:19d6fd892e112cf7dc300735a7ac8e596813ff1c11649ba2d2b9dd6fdfd6c96e", size = 5980270, upload-time = "2026-08-27T03:50:02.675Z" }, + { url = "https://files.pythonhosted.org/packages/9d/b6/bbf5134c7b1360aad3f62c0f0391a7c6403b3500d16df25848089ad957c3/prek-0.5.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:313e2535483c37bba884ab034f478c174de7d3b9627081f2eedd16ac2c03bcc3", size = 5521835, upload-time = "2026-08-27T03:50:04.609Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/7c79e9de5603fdc54d2101f0982f1af5e5ffa1a45c0bcbdbe5f985ed6b90/prek-0.5.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:1e9b34caeb465f1fa7cbb5d872d4611560eaff877162c4ad80a608838b0819a3", size = 5822039, upload-time = "2026-08-27T03:50:07.281Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d2/68c21ec26f773b2370b132d69fe325589db08865fbe8b3df7d8ea8d8fc49/prek-0.5.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:561c2d028c7679fe8969937aa9ab74d96d6f7c748b8abf98edf6dc031dfbbdc9", size = 5510802, upload-time = "2026-08-27T03:50:09.2Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/ce6db8d998cb3e48bf747b7390e1ec87bb817ec500cfa5791af3a0d2eded/prek-0.5.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:310579ceb4acbc5646df9d663ec38a57b47258a903780089cfacfbf0dc9d49f1", size = 5969141, upload-time = "2026-08-27T03:50:11.211Z" }, + { url = "https://files.pythonhosted.org/packages/5a/10/35e4c8fc4534d3b98c1bef2bfd559ba2aeedc9e540fcd4091fd4d91543b6/prek-0.5.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:da38e9c1d227773728c05d2f644704f3d3ace67f6ea58e41d406ebb596c3a135", size = 6731634, upload-time = "2026-08-27T03:50:13.244Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fc/433b55d10e928de8ab2a9b83dfdbe8cba936fafc6e0a5c65b85f4007a8ea/prek-0.5.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b845f25f481a7df56f39dddcba35576a10d78aa71e09e4f88bcccce0729ebfb2", size = 6203980, upload-time = "2026-08-27T03:50:15.158Z" }, + { url = "https://files.pythonhosted.org/packages/7b/b8/b5cdd27d6da0d754abcf9cc06a092f476bd9242f0e8e19e3dfd1a2d1b315/prek-0.5.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:93b4086a1b69c24a967b5c80608cfc4a111cc2b0f69bc1d1f278eb4c23ad430c", size = 5827384, upload-time = "2026-08-27T03:50:16.898Z" }, + { url = "https://files.pythonhosted.org/packages/18/e5/1d8fd614dfe04aa9378b33fb23f7285da582ca090c321973da8466a754d5/prek-0.5.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3299eca7269582665255f4c8f3ab38bc64e4d16ab2e9ce5e3d20cf65bc9f40ee", size = 5589195, upload-time = "2026-08-27T03:50:18.857Z" }, + { url = "https://files.pythonhosted.org/packages/6c/89/e758cefe423cd1422e882325baef65af2bf2b6a903c32d657323a993d394/prek-0.5.0-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:005c20b3df988f240bfa50518777d5babeab50cf774a8917dba1705567e1f561", size = 5491009, upload-time = "2026-08-27T03:50:20.836Z" }, + { url = "https://files.pythonhosted.org/packages/ea/90/6b30d898a2610113378b78b82f1d700bcdb79993ba92b85f492a02859f53/prek-0.5.0-py3-none-musllinux_1_1_i686.whl", hash = "sha256:a1a46d8de94d7c7c58b027a3622763861f1c76b01bafe9dc6d8b408713ad3946", size = 5826007, upload-time = "2026-08-27T03:50:23.1Z" }, + { url = "https://files.pythonhosted.org/packages/48/23/3fd00ff6d844b1756b95fc913730c6ec2f772f36558693ff3c4cfb2d3071/prek-0.5.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:e2aad66e0111cab24a03af2f67fca737c3d8f7b1188cf9b8994bbbf7b52f53b8", size = 6319092, upload-time = "2026-08-27T03:50:25.41Z" }, + { url = "https://files.pythonhosted.org/packages/ca/10/5d99bdfc77b51ab427a139e0b9f2dd5eacc74fe02fd1228c7be1a8ede6ad/prek-0.5.0-py3-none-win32.whl", hash = "sha256:d4970878d5032ad101b4ec5662f3848a70f80c0295ed5a1fea0aed82979c1251", size = 5340535, upload-time = "2026-08-27T03:50:27.452Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2a/835019081b3a09acefc6e28a6dd561a45c8270f8a84e85fa00d6f2be0300/prek-0.5.0-py3-none-win_amd64.whl", hash = "sha256:7bf2df923ba48ec72af7dc69033d613f4775eb301cdf975e19e749ecda2a28c4", size = 5725188, upload-time = "2026-08-27T03:50:29.492Z" }, + { url = "https://files.pythonhosted.org/packages/55/dc/a634d4c5951bd899526c6d8f2a4776bc42af1df4b01951e4f90412909cfc/prek-0.5.0-py3-none-win_arm64.whl", hash = "sha256:43e9d2d727435b0be28fe2195a6a22b0e5e0d937270ae0c88edfc42b71db8fab", size = 5492540, upload-time = "2026-08-27T03:50:31.49Z" }, ] [[package]] @@ -5650,7 +5650,7 @@ name = "prompt-toolkit" version = "3.0.53" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "wcwidth", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "wcwidth" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" } wheels = [ @@ -5662,8 +5662,8 @@ name = "prompty" version = "2.0.0b3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiofiles", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "aiofiles" }, + { name = "pyyaml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b7/8a/9a846231871a217259c5716ed359a85718821c73f7c017072192d285ab35/prompty-2.0.0b3.tar.gz", hash = "sha256:572167004a66607fdb2c9d509fbc17aca36e8fd8db10502156fb2dbb0d686fae", size = 12298730, upload-time = "2026-06-30T21:57:12.223Z" } wheels = [ @@ -5786,7 +5786,7 @@ name = "proto-plus" version = "1.28.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "protobuf" }, ] sdist = { url = "https://files.pythonhosted.org/packages/26/6a/056256feb4bd000869aba5c16cf2aa911572ca2a2feb185f86e457b5171e/proto_plus-1.28.3.tar.gz", hash = "sha256:5f91b30dafa6bb38d432c5557a6ee1d35ffd40b4b1e0e3ca27260448560b91d9", size = 58051, upload-time = "2026-08-06T06:24:55.581Z" } wheels = [ @@ -5880,7 +5880,7 @@ name = "pyasn1-modules" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyasn1", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pyasn1" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } wheels = [ @@ -5901,10 +5901,10 @@ name = "pydantic" version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-types", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, ] sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ @@ -5913,7 +5913,7 @@ wheels = [ [package.optional-dependencies] email = [ - { name = "email-validator", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "email-validator" }, ] [[package]] @@ -5921,7 +5921,7 @@ name = "pydantic-argparse" version = "0.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic" }, ] sdist = { url = "https://files.pythonhosted.org/packages/94/ea/e63d587294c20d3b83e9c312b5d577c9ec28962ee8490839ca9996672849/pydantic_argparse-0.10.0.tar.gz", hash = "sha256:d57eb0a84c8f0af6605376157d3f445cfd786700f2e596ba9d48d15d557185eb", size = 15928, upload-time = "2025-02-09T08:18:30.425Z" } wheels = [ @@ -5933,7 +5933,7 @@ name = "pydantic-core" version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ @@ -6035,8 +6035,8 @@ name = "pydantic-monty" version = "0.0.19" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic-monty-runtime", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic-monty-runtime" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/51/f8/c04df414086488834d102ab85cf8172c114108f842fb142ac92a490213fd/pydantic_monty-0.0.19.tar.gz", hash = "sha256:f3f9e256058b4085349dd4ad347795d5203a8310e9eaad9a2bff93890ac5b86d", size = 1484032, upload-time = "2026-07-24T10:00:13.194Z" } wheels = [ @@ -6151,9 +6151,9 @@ name = "pydantic-settings" version = "2.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, ] sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } wheels = [ @@ -6180,7 +6180,7 @@ wheels = [ [package.optional-dependencies] crypto = [ - { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "cryptography" }, ] [[package]] @@ -6188,7 +6188,7 @@ name = "pynacl" version = "1.6.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (platform_python_implementation != 'PyPy' and sys_platform == 'win32')" }, + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } wheels = [ @@ -6251,8 +6251,8 @@ name = "pyright" version = "1.1.411" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nodeenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nodeenv" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } wheels = [ @@ -6264,7 +6264,7 @@ name = "pyroscope-io" version = "0.8.16" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "cffi" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a8/50/607b38b120ba8adad954119ba512c53590c793f0cf7f009ba6549e4e1d77/pyroscope_io-0.8.16-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:e07edcfd59f5bdce42948b92c9b118c824edbd551730305f095a6b9af401a9e8", size = 3138869, upload-time = "2026-01-22T06:23:24.664Z" }, @@ -6279,10 +6279,10 @@ version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pluggy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pygments", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ @@ -6294,8 +6294,8 @@ name = "pytest-asyncio" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } wheels = [ @@ -6307,9 +6307,9 @@ name = "pytest-cov" version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coverage", extra = ["toml"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pluggy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ @@ -6321,7 +6321,7 @@ name = "pytest-retry" version = "1.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c5/5b/607b017994cca28de3a1ad22a3eee8418e5d428dcd8ec25b26b18e995a73/pytest_retry-1.7.0.tar.gz", hash = "sha256:f8d52339f01e949df47c11ba9ee8d5b362f5824dff580d3870ec9ae0057df80f", size = 19977, upload-time = "2025-01-19T01:56:13.115Z" } wheels = [ @@ -6333,7 +6333,7 @@ name = "pytest-timeout" version = "2.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } wheels = [ @@ -6345,8 +6345,8 @@ name = "pytest-xdist" version = "3.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "execnet", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "execnet" }, + { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } wheels = [ @@ -6355,7 +6355,7 @@ wheels = [ [package.optional-dependencies] psutil = [ - { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "psutil" }, ] [[package]] @@ -6363,7 +6363,7 @@ name = "python-dateutil" version = "2.9.0.post0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "six" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ @@ -6402,7 +6402,7 @@ name = "pythonnet" version = "3.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "clr-loader", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, + { name = "clr-loader" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9a/d6/1afd75edd932306ae9bd2c2d961d603dc2b52fcec51b04afea464f1f6646/pythonnet-3.0.5.tar.gz", hash = "sha256:48e43ca463941b3608b32b4e236db92d8d40db4c58a75ace902985f76dac21cf", size = 239212, upload-time = "2024-12-13T08:30:44.393Z" } wheels = [ @@ -6500,14 +6500,14 @@ name = "qdrant-client" version = "1.19.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "grpcio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "httpx", extra = ["http2"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, - { name = "portalocker", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "grpcio" }, + { name = "httpx", extra = ["http2"] }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "portalocker" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "urllib3" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3a/33/c6e4ec45b4fca5a0b808e8804e60be54a6d7505b68b53c0b1d0d62ba86c1/qdrant_client-1.19.0.tar.gz", hash = "sha256:365395a04b0a26c309b25b7d8b1c99ef2071ec9a2b74bc8a5fd3b7a3642fe963", size = 350953, upload-time = "2026-08-04T14:32:56.923Z" } wheels = [ @@ -6519,7 +6519,7 @@ name = "redis" version = "7.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "async-timeout", marker = "(python_full_version < '3.11.3' and sys_platform == 'darwin') or (python_full_version < '3.11.3' and sys_platform == 'linux') or (python_full_version < '3.11.3' and sys_platform == 'win32')" }, + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f7/80/2971931d27651affa88a44c0ad7b8c4a19dc29c998abb20b23868d319b59/redis-7.1.1.tar.gz", hash = "sha256:a2814b2bda15b39dad11391cc48edac4697214a8a5a4bd10abe936ab4892eb43", size = 4800064, upload-time = "2026-02-09T18:39:40.292Z" } wheels = [ @@ -6531,15 +6531,15 @@ name = "redisvl" version = "0.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "jsonpath-ng", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "ml-dtypes", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, - { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-ulid", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "redis", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tenacity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "jsonpath-ng" }, + { name = "ml-dtypes" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pydantic" }, + { name = "python-ulid" }, + { name = "pyyaml" }, + { name = "redis" }, + { name = "tenacity" }, ] sdist = { url = "https://files.pythonhosted.org/packages/72/1a/f1f0ff963622c34a9e9a9f2a0c6ad82bfbd05c082ecc89e38e092e3e9069/redisvl-0.15.0.tar.gz", hash = "sha256:0e382e9b6cd8378dfe1515b18f92d125cfba905f6f3c5fe9b8904b3ca840d1ca", size = 861480, upload-time = "2026-02-27T14:02:33.366Z" } wheels = [ @@ -6551,9 +6551,9 @@ name = "referencing" version = "0.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "attrs", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "rpds-py", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } wheels = [ @@ -6669,10 +6669,10 @@ name = "requests" version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "charset-normalizer", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ @@ -6684,8 +6684,8 @@ name = "requests-oauthlib" version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "oauthlib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "oauthlib" }, + { name = "requests" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } wheels = [ @@ -6706,8 +6706,8 @@ name = "rich" version = "13.9.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pygments", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "markdown-it-py" }, + { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149, upload-time = "2024-11-01T16:43:57.873Z" } wheels = [ @@ -6842,9 +6842,9 @@ name = "rq" version = "2.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "croniter", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "redis", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "click" }, + { name = "croniter" }, + { name = "redis" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cc/a8/7bf65cda593feb5888214a4d1e64aae6fc5eb0bbbb74df922c916099a3e5/rq-2.10.0.tar.gz", hash = "sha256:2d8c533dd27500fedabec06295f18db595966e4f22744e6988fe31155b8f7a21", size = 754610, upload-time = "2026-06-20T03:11:45.919Z" } wheels = [ @@ -6853,27 +6853,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" }, - { url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" }, - { url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" }, - { url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" }, - { url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" }, - { url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" }, - { url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" }, - { url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" }, - { url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" }, - { url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" }, - { url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" }, - { url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" }, - { url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" }, - { url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687, upload-time = "2026-08-20T17:43:52.281Z" }, - { url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" }, - { url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" }, +version = "0.16.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" }, + { url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" }, + { url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" }, + { url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" }, + { url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" }, + { url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" }, + { url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" }, + { url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" }, + { url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" }, ] [[package]] @@ -6881,7 +6881,7 @@ name = "s3transfer" version = "0.19.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "botocore", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "botocore" }, ] sdist = { url = "https://files.pythonhosted.org/packages/76/43/35e4d8aa320bffe8287fe8f65f578fa2d2db0a64212f0e710dce58267854/s3transfer-0.19.2.tar.gz", hash = "sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993", size = 165592, upload-time = "2026-07-22T19:30:44.432Z" } wheels = [ @@ -6893,13 +6893,13 @@ name = "scikit-learn" version = "1.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "joblib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "narwhals", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')" }, - { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, - { name = "threadpoolctl", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "joblib" }, + { name = "narwhals" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "threadpoolctl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } wheels = [ @@ -6945,7 +6945,7 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform == 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -7030,7 +7030,7 @@ resolution-markers = [ "python_full_version == '3.12.*' and sys_platform == 'win32'", ] dependencies = [ - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } wheels = [ @@ -7081,10 +7081,10 @@ name = "seaborn" version = "0.13.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "matplotlib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, - { name = "pandas", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "matplotlib" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pandas" }, ] sdist = { url = "https://files.pythonhosted.org/packages/86/59/a451d7420a77ab0b98f7affa3a1d78a313d2f7281a57afb1a34bae8ab412/seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7", size = 1457696, upload-time = "2024-01-25T13:21:52.551Z" } wheels = [ @@ -7194,10 +7194,10 @@ name = "soundfile" version = "0.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform == 'win32')" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "cffi" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d2/db/949331952a6fb1c5b12e9de80fd08747966c2039d1a61db4764fbd3981c2/soundfile-0.14.0.tar.gz", hash = "sha256:ba1c1a2d618bca5c406647c83b89f07cc8810fa506a50622a6993ba130c1de11", size = 47842, upload-time = "2026-06-06T08:58:47.869Z" } wheels = [ @@ -7216,8 +7216,8 @@ name = "sqlalchemy" version = "2.0.51" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "greenlet", marker = "(platform_machine == 'AMD64' and sys_platform == 'darwin') or (platform_machine == 'WIN32' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'darwin') or (platform_machine == 'amd64' and sys_platform == 'darwin') or (platform_machine == 'ppc64le' and sys_platform == 'darwin') or (platform_machine == 'win32' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'AMD64' and sys_platform == 'linux') or (platform_machine == 'WIN32' and sys_platform == 'linux') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'amd64' and sys_platform == 'linux') or (platform_machine == 'ppc64le' and sys_platform == 'linux') or (platform_machine == 'win32' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'WIN32' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'win32') or (platform_machine == 'amd64' and sys_platform == 'win32') or (platform_machine == 'ppc64le' and sys_platform == 'win32') or (platform_machine == 'win32' and sys_platform == 'win32') or (platform_machine == 'x86_64' and sys_platform == 'win32')" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } wheels = [ @@ -7264,8 +7264,8 @@ name = "sse-starlette" version = "3.4.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "anyio" }, + { name = "starlette" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f8/00/b42a44342a054d58cb1115d7c8aa9cb4290dd9442f9c1b91a4b8173dba22/sse_starlette-3.4.8.tar.gz", hash = "sha256:ed89ffbb75cbf78a5fe2f2109cd584792ee7f9dfac96f791db546df8f15f3f9c", size = 32548, upload-time = "2026-08-05T11:19:49.982Z" } wheels = [ @@ -7277,8 +7277,8 @@ name = "starlette" version = "1.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/3c/76d2fd1f1357ed0f0108d8a5aa233dcf16e2946a8559c84912fe08e01ac7/starlette-1.4.1.tar.gz", hash = "sha256:b7332de6e9375593a29ba9eee1e6ecfeb3eb2043e2e19a13b4b71da73ff35540", size = 2709041, upload-time = "2026-08-05T15:17:26.23Z" } wheels = [ @@ -7290,7 +7290,7 @@ name = "sympy" version = "1.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mpmath", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "mpmath" }, ] sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } wheels = [ @@ -7311,31 +7311,31 @@ name = "tau2" version = "0.0.1" source = { git = "https://github.com/sierra-research/tau2-bench?rev=5ba9e3e56db57c5e4114bf7f901291f09b2c5619#5ba9e3e56db57c5e4114bf7f901291f09b2c5619" } dependencies = [ - { name = "addict", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "deepdiff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "docstring-parser", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "fs", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "langfuse", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "litellm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "loguru", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "matplotlib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pandas", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "plotly", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic-argparse", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "redis", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "ruff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "scikit-learn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "seaborn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tabulate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "tenacity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "toml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uvicorn", extra = ["standard"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "watchdog", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "addict" }, + { name = "deepdiff" }, + { name = "docstring-parser" }, + { name = "fastapi" }, + { name = "fs" }, + { name = "langfuse" }, + { name = "litellm" }, + { name = "loguru" }, + { name = "matplotlib" }, + { name = "pandas" }, + { name = "plotly" }, + { name = "psutil" }, + { name = "pydantic-argparse" }, + { name = "pytest" }, + { name = "pyyaml" }, + { name = "redis" }, + { name = "rich" }, + { name = "ruff" }, + { name = "scikit-learn" }, + { name = "seaborn" }, + { name = "tabulate" }, + { name = "tenacity" }, + { name = "toml" }, + { name = "uvicorn", extra = ["standard"] }, + { name = "watchdog" }, ] [[package]] @@ -7370,8 +7370,8 @@ name = "tiktoken" version = "0.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "regex", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "regex" }, + { name = "requests" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/e5/5f3cb2159769d0f4324c0e9e87f9de3c4b1cd45848a96b2eb3566ad5ca77/tiktoken-0.13.0.tar.gz", hash = "sha256:c9435714c3a84c2319499de9a300c0e604449dd0799ff246458b3bb6a7f433c1", size = 38986, upload-time = "2026-05-15T04:51:27.153Z" } wheels = [ @@ -7424,7 +7424,7 @@ name = "tokenizers" version = "0.23.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "huggingface-hub" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c1/60/21f715d9faba5f5407ff759472ade058ec4a507ad62bcea47cb847239a73/tokenizers-0.23.1.tar.gz", hash = "sha256:1feeeadf865a7915adc25445dea30e9933e593c31bb96c277cee36de227c8bfa", size = 365748, upload-time = "2026-04-27T14:43:25.606Z" } wheels = [ @@ -7578,7 +7578,7 @@ name = "typing-inspection" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ @@ -7617,28 +7617,28 @@ wheels = [ [[package]] name = "uv" -version = "0.12.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9b/26/c2cc420ebadf9a3d3170b6949d21071e2a50761e24b7d352ed2cf9d86de5/uv-0.12.6.tar.gz", hash = "sha256:7c687a6c88b4606b08cd27de29557ee0bf2fff6d2d946e9f1954e8c01745e0b1", size = 7117263, upload-time = "2026-08-25T19:40:02.286Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/94/5326c129967cee589cae13e5aea1a2be0d34b3590ec52cb3fb8693f9ca75/uv-0.12.6-py3-none-linux_armv6l.whl", hash = "sha256:348c35bfe137c539ff8c785e2c98bfb3ad7a2077699abf0b38b3b729386edce7", size = 23081229, upload-time = "2026-08-25T19:38:55.356Z" }, - { url = "https://files.pythonhosted.org/packages/20/8d/dc29316192b7f449ddb0f0796c3f169aa4870b62dc65015d2e3305eecce4/uv-0.12.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:596e24f75b8b81d602228d00c4d7674b53013e94f04e22155f2c5486c3052438", size = 21296317, upload-time = "2026-08-25T19:38:59.08Z" }, - { url = "https://files.pythonhosted.org/packages/ef/5d/4c41b08b4e09729dd50f08b55238d5248a7bfb1a9f7cfba1c162046cfa1c/uv-0.12.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:55bc16b317b2d6044c402e3b1f2c6a8daa031407e9efa7ecbf5348ac0a1c0df5", size = 18115315, upload-time = "2026-08-25T19:39:02.354Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3c/fe2c8b17df5302ea438ac595b5978210bcde9b23adf4fff755e592574fbf/uv-0.12.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:1226946a479a5ae9be4132dafb1a02303be2eb4d39e931d4310088d1c5e4d3a7", size = 22413322, upload-time = "2026-08-25T19:39:05.926Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9d/195a98472a4971fb8045889fa0f04960c89bec22afb42af1f438119e9a4a/uv-0.12.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:4782d51befb745b378e155aafa00976377536c2c28bf214cea2068fc351b9081", size = 22503804, upload-time = "2026-08-25T19:39:09.605Z" }, - { url = "https://files.pythonhosted.org/packages/54/0c/3872d536cd809ffa87af6f3a116399857a48f2664ba4b21b38c5416535df/uv-0.12.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f4f135204aa5236c94348c073fafbb00a7c00c23c25a5ab517aff8e68127dc3", size = 22565554, upload-time = "2026-08-25T19:39:13.286Z" }, - { url = "https://files.pythonhosted.org/packages/77/bc/0fe96033ef905a2729fe512c73c08e541fc0427724777f01c2b934e5cfdd/uv-0.12.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09ce6415e7bf2e9482f66be2d131312b96c72e2e9c4bba2f4084c67515309fed", size = 23136010, upload-time = "2026-08-25T19:39:16.87Z" }, - { url = "https://files.pythonhosted.org/packages/73/91/21756319256364d350ba38f1b3a16bd64ff726865d2d6bb677633fce3cb6/uv-0.12.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db0888a800bfb661c0054c871d04d0b6ef4b8986e01222854c13346371d2b22d", size = 24466274, upload-time = "2026-08-25T19:39:20.723Z" }, - { url = "https://files.pythonhosted.org/packages/5e/49/38883c7734c13d1bc03081ec59c7e9ddd595460ad2fcfbd1a8220e02aa55/uv-0.12.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cb3907b001b0054e8c81621455fb7aca26b4aa1fead35b3165013aadc5eb7ebd", size = 24318234, upload-time = "2026-08-25T19:39:25.019Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6b/dbcffa319e640b3f38fc8764b99031773d4449725ca21854309e436c08f9/uv-0.12.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cb1c4af10a1037d2e875ac86da32ea0df20a9623e11769d33515d14158cd3c2", size = 20927695, upload-time = "2026-08-25T19:39:28.77Z" }, - { url = "https://files.pythonhosted.org/packages/e0/cc/4bde66c453d37e40c5861931f75fb66d207328f5ccbb18d44f818c81d920/uv-0.12.6-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:f8f11ae7d78092384679eac086210d63261af241c56d7c46fa4ded62400e56ee", size = 20273626, upload-time = "2026-08-25T19:39:32.384Z" }, - { url = "https://files.pythonhosted.org/packages/8a/1b/e0c92c3c81b32c7bb70e375b0d218ca6876100cf113765005332454c61f1/uv-0.12.6-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:c874e7077a7a9210531ee361843b7e6a50f8c71a45a3880aab289079042bfbaf", size = 23279870, upload-time = "2026-08-25T19:39:36.485Z" }, - { url = "https://files.pythonhosted.org/packages/65/fc/d24268fa3a39de0d79494fdc8c85000b6ef5b015754fb71b53d5912a2d84/uv-0.12.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ca17e4d2213c4f756a37077776e93202107eb10a71d05e3f9f91f678edcb0239", size = 23375132, upload-time = "2026-08-25T19:39:40.13Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ed/5903a2c3dd56b1c77f97c2d3f1c114f6247db0718d60afb8d08b2c84efa4/uv-0.12.6-py3-none-musllinux_1_1_i686.whl", hash = "sha256:de0c4119af8b7ec15f4d3630d7fb2d9b37fee752093e67a4365df395ffbbf3d7", size = 22276726, upload-time = "2026-08-25T19:39:43.786Z" }, - { url = "https://files.pythonhosted.org/packages/47/51/d9a3032bd233a1562b3bc90bbb5cca2aa6fa9669f4c1056ebe351b2f09fe/uv-0.12.6-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:b7f850c30395026aa024b72a8833133ed71a832fa843c6c59496d8f393cce726", size = 23759803, upload-time = "2026-08-25T19:39:47.72Z" }, - { url = "https://files.pythonhosted.org/packages/e7/b9/e91d756ce38a182e58b5731bcef66a428bc492facd0941cb46be14808830/uv-0.12.6-py3-none-win32.whl", hash = "sha256:99f1a686675befc705530e8cca4a7b066383bd4650967d410abf819ab52bc5ab", size = 20699250, upload-time = "2026-08-25T19:39:51.608Z" }, - { url = "https://files.pythonhosted.org/packages/11/69/c23e5b30db494f9b6e62e8d34ee87da2673c071d4f1518f89e93d52d1e2a/uv-0.12.6-py3-none-win_amd64.whl", hash = "sha256:8fa8fe6d7f4d9a897dd30159426e712094727494fbb7ef435bba3f83030b9fd7", size = 18927528, upload-time = "2026-08-25T19:39:55.299Z" }, - { url = "https://files.pythonhosted.org/packages/d4/41/b5b131a9c96e0c9e129edf30b1daffb3d8f8fbc3e9fc087624ffdd4700ad/uv-0.12.6-py3-none-win_arm64.whl", hash = "sha256:8a93a8c142940c106375cc50743faa7c610b6194e2e9e8da925a61aefb0302e6", size = 20332543, upload-time = "2026-08-25T19:39:59.173Z" }, +version = "0.12.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/ab/790e25d2c685f5a41b9567001a43fbe049a595c1b08b8a93bf82d5fc704b/uv-0.12.7.tar.gz", hash = "sha256:4b320f84763a80308fd830ecf5c4c44505a8ed910fe265c5977d0a3727cfcd55", size = 7107999, upload-time = "2026-08-27T22:13:22.978Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/63/cc236f80a868b28525e0f7aa61f7ee998a0540ccba8cc4d01b2d42fc7838/uv-0.12.7-py3-none-linux_armv6l.whl", hash = "sha256:d568fd3448c24354753fd8333c978aeeeb6b51db4b100e234461a7eb50882fae", size = 22215689, upload-time = "2026-08-27T22:12:36.485Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c5/40f4a5736d2f795688977b3b8bf841daca1e9c93b358778b7ed3dcd747fe/uv-0.12.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ec5b437aa60e8c94da263ad709d0bf6c8f268ac81f305d89c6115badd7d1cbe7", size = 20471214, upload-time = "2026-08-27T22:12:39.147Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3e/5946d33de437467190b51d7d9f9e74a53628782565d50b64f6051050f482/uv-0.12.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:016fe4b9a2e0d2a35b17b6c3efbb45b929189c5b4b37aa921265265ccfe42cc1", size = 17317478, upload-time = "2026-08-27T22:12:41.58Z" }, + { url = "https://files.pythonhosted.org/packages/2e/5b/0e42dd88d9928b51a4e85d89d8aed46933951187b508ae68c145b06e3334/uv-0.12.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:fe9a871bd638ee6d2fd73bf40c2ee98153e44d06f796a03fcecf9d12b36d42d8", size = 21379352, upload-time = "2026-08-27T22:12:44.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/5c/b6417812ef87fc713ec368b1001b41c78eaedbfa521789db7d103b56c500/uv-0.12.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:1014a13854c45eb1daa9a32602e0f4d07f3edd298826d3e8d22740eed60a7c95", size = 21686985, upload-time = "2026-08-27T22:12:47.686Z" }, + { url = "https://files.pythonhosted.org/packages/69/23/a6284df1dc4233fea564227f77c402969b4f6aebcb647826ce9e82a413de/uv-0.12.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0ad3e91cc911596bb54197057853b64b36a066462d8f2fc4d4f60b61b707ffa8", size = 21744177, upload-time = "2026-08-27T22:12:50.572Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e1/96526f3ea72667352e1a83dff3cbb8701dbfc830aafb3ac9dd532d4fb827/uv-0.12.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b6d4bd67b488ef2766cfa885947c1093c18caf5d665ba9156963ad0241f196e9", size = 22527940, upload-time = "2026-08-27T22:12:53.479Z" }, + { url = "https://files.pythonhosted.org/packages/39/7b/816e7c4ef1b59faf295d4eec133b15d6d3a227dc4bc341dceccc31a3ba15/uv-0.12.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:debeccc5eca0063cd922bc67caa4a8c0df5f69090179866ed17fa7264905bda2", size = 23630281, upload-time = "2026-08-27T22:12:55.941Z" }, + { url = "https://files.pythonhosted.org/packages/ac/63/03d00b74c5582163b8c544b2dc39396388d904a13118303fb2c32ece2811/uv-0.12.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:36c8f93d182b766b9ed4a9c1da5ec0f7dc9f934887df3404d996f66321fd18d5", size = 23345465, upload-time = "2026-08-27T22:12:58.726Z" }, + { url = "https://files.pythonhosted.org/packages/8a/01/616cc5f80952eef6fa48c5dbd6ea8aeed04a494e7dfbe9f5f65cfcf85ad9/uv-0.12.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4545e87c7ac64af317d8daffd279e23e93b0e05035662363033d3525923339d2", size = 20058968, upload-time = "2026-08-27T22:13:01.239Z" }, + { url = "https://files.pythonhosted.org/packages/66/23/051acc24b21bbf0e67bc00e2b4239892e37cf39fdd6428fc246a2ae62b79/uv-0.12.7-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:56a5730f8eff477501b3276a0059c2c2843302d5d4a6cc10f993a5cd66ddace8", size = 19450815, upload-time = "2026-08-27T22:13:03.636Z" }, + { url = "https://files.pythonhosted.org/packages/42/a4/a84adae83d6891401284ec4929ea7892c7efc5484cb53283147fbb4881b6/uv-0.12.7-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:d83419298e202f56e381cef6406b519b9336e58fc90a559617d86576a3d8a4e8", size = 22402981, upload-time = "2026-08-27T22:13:06.247Z" }, + { url = "https://files.pythonhosted.org/packages/8a/35/6fcdff1292ba1cc027d1d741531dfc4d19f92e1312262833beacf3b7fbe8/uv-0.12.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fc57436f2f012b885454f465dbf077f573745f0d4275a6a38194203b67e94ea4", size = 22535567, upload-time = "2026-08-27T22:13:08.802Z" }, + { url = "https://files.pythonhosted.org/packages/2b/ea/7ac04384642a4ba808b20a94cb09e0f4b4a6aef6cf9671858be0567c153a/uv-0.12.7-py3-none-musllinux_1_1_i686.whl", hash = "sha256:b2bd0f25f17f0000a2415347471e713cd1597f4525cd3412d17875b131f4b1ac", size = 21667314, upload-time = "2026-08-27T22:13:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c8/348089faf45a3c24c1d5a5ee3957cd1c7fb4a6aef1d9646114a4b7cc9c7a/uv-0.12.7-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:ff33305718665c6fba25efdd260c67a6bd500c665e3d5d61059612791ca10c90", size = 22919105, upload-time = "2026-08-27T22:13:13.596Z" }, + { url = "https://files.pythonhosted.org/packages/3e/7f/5fb8ed68270666ed675daead854bde3b14d7ab42c2b7c0d234b5ea8ea928/uv-0.12.7-py3-none-win32.whl", hash = "sha256:3ac3321ccd6097dbef154d27044e0762a67b2f6eb017dcc65be6574f4671fb0d", size = 19880506, upload-time = "2026-08-27T22:13:16.072Z" }, + { url = "https://files.pythonhosted.org/packages/e7/97/40a91354862028e8f8e547ac27c6acd7b7cc67b9003acc28596d9e90cd6b/uv-0.12.7-py3-none-win_amd64.whl", hash = "sha256:277d326d7e63b912f3425c6e6d7d5d49f21b43d080d21859ff3c6819353f1847", size = 18085016, upload-time = "2026-08-27T22:13:18.352Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/6cdd022fff6a8d8f5c9af2686f61f2605ddd03b5709efb73df6e08f40249/uv-0.12.7-py3-none-win_arm64.whl", hash = "sha256:95c3a4fa65e72bab3ca1b4c8ce18fbe784cf3137e9f9234588b69d09b341a4a7", size = 19522843, upload-time = "2026-08-27T22:13:20.633Z" }, ] [[package]] @@ -7646,8 +7646,8 @@ name = "uvicorn" version = "0.52.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "h11", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "click" }, + { name = "h11" }, ] sdist = { url = "https://files.pythonhosted.org/packages/03/18/ccce41535dee1be77735592bd19965f3972c82e07ee703d324709496b716/uvicorn-0.52.1.tar.gz", hash = "sha256:112ec661814189acbccd3f7b86460147cc065fc92c0821afa78918780e4354dd", size = 100571, upload-time = "2026-08-01T18:19:30.732Z" } wheels = [ @@ -7656,12 +7656,12 @@ wheels = [ [package.optional-dependencies] standard = [ - { name = "httptools", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uvloop", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'linux')" }, - { name = "watchfiles", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "websockets", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, ] [[package]] @@ -7669,8 +7669,8 @@ name = "uvicorn-worker" version = "0.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "gunicorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "uvicorn", extra = ["standard"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "gunicorn" }, + { name = "uvicorn", extra = ["standard"] }, ] sdist = { url = "https://files.pythonhosted.org/packages/80/59/9101b9c0680fd80e9d26c07deb822a5d18a324339fcf9cd017885ee808ad/uvicorn_worker-0.4.0.tar.gz", hash = "sha256:8ee5306070d8f38dce124adce488c3c0b50f20cf0c0222b12c66188da7214493", size = 9361, upload-time = "2025-09-20T10:47:01.218Z" } wheels = [ @@ -7747,7 +7747,7 @@ name = "watchfiles" version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "anyio" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } wheels = [ @@ -7902,7 +7902,7 @@ name = "werkzeug" version = "3.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markupsafe", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "markupsafe" }, ] sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } wheels = [ @@ -7982,7 +7982,7 @@ name = "wsproto" version = "1.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "h11" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } wheels = [ @@ -7994,9 +7994,9 @@ name = "yarl" version = "1.24.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "multidict", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "propcache", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, ] sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } wheels = [ From 2ca7b029971528c07929ae542dcb60f5aced3320 Mon Sep 17 00:00:00 2001 From: "Shivani ." Date: Mon, 7 Sep 2026 22:36:56 +0000 Subject: [PATCH 60/85] Python: Finalize abandoned functional workflow streams without ContextVar leaks (#7798) * Python: Finalize abandoned functional workflow streams without ContextVar leaks Stop holding the run span and event-origin token across yields so breaking out of a streaming functional workflow no longer fails during GC. * Python: Unlock abandoned functional streams and cover OTel parenting Reset the run guard in the generator finally so a follow-up run after GC is allowed, and assert no OpenTelemetry detach errors plus nested-span parenting under workflow.run. * Python: Fix typing checks for abandoned-stream PR follow-ups Resolve pyright reportUnusedFunction on _framework_event helpers and add span attribute subscript suppressions in the unattached workflow span test. --- .../agent_framework/_workflows/_events.py | 33 +++- .../agent_framework/_workflows/_functional.py | 183 ++++++++++-------- .../core/agent_framework/observability.py | 43 +++- .../core/tests/core/test_observability.py | 20 +- .../workflow/test_functional_workflow.py | 91 +++++++++ 5 files changed, 272 insertions(+), 98 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_events.py b/python/packages/core/agent_framework/_workflows/_events.py index 669887a3c29..6676e9a8275 100644 --- a/python/packages/core/agent_framework/_workflows/_events.py +++ b/python/packages/core/agent_framework/_workflows/_events.py @@ -6,8 +6,8 @@ import sys import traceback as _traceback import warnings -from collections.abc import Generator, Mapping -from contextlib import contextmanager +from collections.abc import Callable, Generator, Mapping +from contextlib import contextmanager, suppress from contextvars import ContextVar from dataclasses import dataclass from enum import Enum @@ -46,13 +46,36 @@ def _current_event_origin() -> WorkflowEventSource: @contextmanager -def _framework_event_origin() -> Generator[None]: # pyright: ignore[reportUnusedFunction] - """Temporarily mark subsequently created events as originating from the framework (internal).""" +def _framework_event_origin() -> Generator[None]: + """Temporarily mark subsequently created events as originating from the framework (internal). + + Callers must not ``yield`` from an async generator while this manager is active. + Async-generator finalization can inject ``GeneratorExit`` from a different + ``Context`` than the one that created the token (for example when an abandoned + ``ResponseStream`` is garbage-collected), and ``ContextVar.reset`` then raises. + """ token = _event_origin_context.set(WorkflowEventSource.FRAMEWORK) try: yield finally: - _event_origin_context.reset(token) + with suppress(ValueError): + # Token may have been created in a different Context when an + # abandoned ResponseStream is garbage-collected. Leave the var + # as-is rather than raising during generator/GC cleanup. + _event_origin_context.reset(token) + + +def _framework_event( # pyright: ignore[reportUnusedFunction] + factory: Callable[..., WorkflowEvent[Any]], *args: Any, **kwargs: Any +) -> WorkflowEvent[Any]: + """Build a framework-origin event and return it after resetting the origin token. + + Callers can ``yield`` the result without holding ``_framework_event_origin()`` + across an async-generator yield, which would leak the ContextVar token if the + stream is abandoned and finalized from a different Context. + """ + with _framework_event_origin(): + return factory(*args, **kwargs) class WorkflowRunState(str, Enum): diff --git a/python/packages/core/agent_framework/_workflows/_functional.py b/python/packages/core/agent_framework/_workflows/_functional.py index 9e4deb340bb..e83fa9e0e5c 100644 --- a/python/packages/core/agent_framework/_workflows/_functional.py +++ b/python/packages/core/agent_framework/_workflows/_functional.py @@ -52,13 +52,18 @@ from .._feature_stage import ExperimentalFeature, experimental from .._serialization import make_json_safe from .._types import AgentResponse, AgentResponseUpdate, ResponseStream -from ..observability import OtelAttr, capture_exception, create_workflow_span +from ..observability import ( + OtelAttr, + _activate_span, + capture_exception, + start_workflow_span, +) from ._checkpoint import CheckpointStorage, WorkflowCheckpoint from ._events import ( WorkflowErrorDetails, WorkflowEvent, WorkflowRunState, - _framework_event_origin, + _framework_event, ) from ._workflow import WorkflowRunResult @@ -1029,111 +1034,114 @@ async def _on_step_completed() -> None: ctx._on_step_completed = _on_step_completed - # Tracing + # Tracing: start the run span without attaching it. Attaching with + # create_workflow_span() across a yield leaves OpenTelemetry's context + # token set when this generator is later closed on GC from a different + # Context. Activate the span only around non-yielding work. attributes: dict[str, Any] = {OtelAttr.WORKFLOW_NAME: self.name} if self.description: attributes[OtelAttr.WORKFLOW_DESCRIPTION] = self.description - with create_workflow_span(OtelAttr.WORKFLOW_RUN_SPAN, attributes) as span: - saw_request = False - try: - span.add_event(OtelAttr.WORKFLOW_STARTED) + span = start_workflow_span(OtelAttr.WORKFLOW_RUN_SPAN, attributes) + saw_request = False + try: + span.add_event(OtelAttr.WORKFLOW_STARTED) - with _framework_event_origin(): - yield WorkflowEvent.started() - with _framework_event_origin(): - yield WorkflowEvent.status(WorkflowRunState.IN_PROGRESS) + yield _framework_event(WorkflowEvent.started) + yield _framework_event(WorkflowEvent.status, WorkflowRunState.IN_PROGRESS) - # Execute the user function + # Execute the user function with the run span current so nested + # executor/processing spans parent correctly. + with _activate_span(span): return_value = await self._execute(ctx, message) # Emit the return value as the workflow output. if return_value is not None: - with _framework_event_origin(): - await ctx.add_event(WorkflowEvent("output", executor_id=self.name, data=return_value)) + await ctx.add_event( + _framework_event(WorkflowEvent, "output", executor_id=self.name, data=return_value) + ) # Persist step cache for response-only replay self._last_step_cache = dict(ctx._step_cache) self._last_step_cache_auto_request_info_counts = dict(ctx._step_cache_auto_request_info_counts) - # Yield collected events. - # NOTE: Events are buffered during _execute() and yielded after - # the user function completes. This is *not* true streaming — - # all events have already been produced by this point. True - # per-token streaming from inner agent calls is a future - # enhancement. - for event in ctx._get_events(): - if event.type == "request_info": - saw_request = True - yield event - if event.type == "request_info": - with _framework_event_origin(): - yield WorkflowEvent.status(WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS) - - # Save final checkpoint if storage is available - if storage is not None: - await self._save_checkpoint(ctx, storage, ckpt_chain[0]) - - # Final status - if saw_request: - self._last_pending_request_ids = set(ctx._pending_requests) - with _framework_event_origin(): - yield WorkflowEvent.status(WorkflowRunState.IDLE_WITH_PENDING_REQUESTS) - else: - # Clean completion — drop cross-run replay state. - self._last_message = None - self._last_step_cache = {} - self._last_step_cache_auto_request_info_counts = {} - self._last_pending_request_ids = set() - with _framework_event_origin(): - yield WorkflowEvent.status(WorkflowRunState.IDLE) + # Yield collected events. + # NOTE: Events are buffered during _execute() and yielded after + # the user function completes. This is *not* true streaming — + # all events have already been produced by this point. True + # per-token streaming from inner agent calls is a future + # enhancement. + for event in ctx._get_events(): + if event.type == "request_info": + saw_request = True + yield event + if event.type == "request_info": + yield _framework_event(WorkflowEvent.status, WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS) + + # Save final checkpoint if storage is available + if storage is not None: + await self._save_checkpoint(ctx, storage, ckpt_chain[0]) + + # Final status + if saw_request: + self._last_pending_request_ids = set(ctx._pending_requests) + yield _framework_event(WorkflowEvent.status, WorkflowRunState.IDLE_WITH_PENDING_REQUESTS) + else: + # Clean completion — drop cross-run replay state. + self._last_message = None + self._last_step_cache = {} + self._last_step_cache_auto_request_info_counts = {} + self._last_pending_request_ids = set() + yield _framework_event(WorkflowEvent.status, WorkflowRunState.IDLE) - span.add_event(OtelAttr.WORKFLOW_COMPLETED) + span.add_event(OtelAttr.WORKFLOW_COMPLETED) - except WorkflowInterrupted: - # Persist step cache for response-only replay - self._last_step_cache = dict(ctx._step_cache) - self._last_step_cache_auto_request_info_counts = dict(ctx._step_cache_auto_request_info_counts) - self._last_pending_request_ids = set(ctx._pending_requests) + except WorkflowInterrupted: + # Persist step cache for response-only replay + self._last_step_cache = dict(ctx._step_cache) + self._last_step_cache_auto_request_info_counts = dict(ctx._step_cache_auto_request_info_counts) + self._last_pending_request_ids = set(ctx._pending_requests) - # HITL interruption — yield events collected so far - for event in ctx._get_events(): - if event.type == "request_info": - saw_request = True - yield event - if event.type == "request_info": - with _framework_event_origin(): - yield WorkflowEvent.status(WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS) + # HITL interruption — yield events collected so far + for event in ctx._get_events(): + if event.type == "request_info": + saw_request = True + yield event + if event.type == "request_info": + yield _framework_event(WorkflowEvent.status, WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS) - # Save checkpoint - if storage is not None: - await self._save_checkpoint(ctx, storage, ckpt_chain[0]) + # Save checkpoint + if storage is not None: + await self._save_checkpoint(ctx, storage, ckpt_chain[0]) - with _framework_event_origin(): - yield WorkflowEvent.status(WorkflowRunState.IDLE_WITH_PENDING_REQUESTS) + yield _framework_event(WorkflowEvent.status, WorkflowRunState.IDLE_WITH_PENDING_REQUESTS) - span.add_event(OtelAttr.WORKFLOW_COMPLETED) + span.add_event(OtelAttr.WORKFLOW_COMPLETED) - except Exception as exc: - # Yield any events collected before the failure - for event in ctx._get_events(): - yield event - - details = WorkflowErrorDetails.from_exception(exc) - with _framework_event_origin(): - yield WorkflowEvent.failed(details) - with _framework_event_origin(): - yield WorkflowEvent.status(WorkflowRunState.FAILED) - - span.add_event( - name=OtelAttr.WORKFLOW_ERROR, - attributes={ - "error.message": str(exc), - "error.type": type(exc).__name__, - }, - ) - capture_exception(span, exception=exc) - raise + except Exception as exc: + # Yield any events collected before the failure + for event in ctx._get_events(): + yield event + + details = WorkflowErrorDetails.from_exception(exc) + yield _framework_event(WorkflowEvent.failed, details) + yield _framework_event(WorkflowEvent.status, WorkflowRunState.FAILED) + + span.add_event( + name=OtelAttr.WORKFLOW_ERROR, + attributes={ + "error.message": str(exc), + "error.type": type(exc).__name__, + }, + ) + capture_exception(span, exception=exc) + raise + finally: + # ResponseStream cleanup_hooks do not run when the generator is + # closed by GC. Release the run lock here so a follow-up run + # after an abandoned stream is not rejected as concurrent. + self._release_run_guard() + span.end() async def _execute(self, ctx: RunContext, message: Any) -> Any: """Run the user's async function with the active context.""" @@ -1296,9 +1304,12 @@ def _ensure_not_running(self) -> None: raise RuntimeError("Workflow is already running. Concurrent executions are not allowed.") self._is_running = True - async def _run_cleanup(self) -> None: + def _release_run_guard(self) -> None: self._is_running = False + async def _run_cleanup(self) -> None: + self._release_run_guard() + # --------------------------------------------------------------------------- # @workflow decorator diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index 9cecdc57fca..5dc9fa8828c 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -3562,17 +3562,48 @@ def workflow_tracer() -> Tracer: return get_tracer() if OBSERVABILITY_SETTINGS.ENABLED else trace.NoOpTracer() -def create_workflow_span( +def _workflow_span_attributes( name: str, attributes: Mapping[str, str | int] | None = None, - kind: trace.SpanKind = trace.SpanKind.INTERNAL, -) -> _AgnosticContextManager[trace.Span]: - """Create a generic workflow span.""" - span_attributes = dict(attributes) if attributes is not None else {} +) -> dict[str, str | int] | None: + span_attributes: dict[str, str | int] = dict(attributes) if attributes is not None else {} conversation_id = _TELEMETRY_CONVERSATION_ID.get() if name == OtelAttr.WORKFLOW_RUN_SPAN and conversation_id is not None: span_attributes.setdefault(OtelAttr.CONVERSATION_ID, conversation_id) - return workflow_tracer().start_as_current_span(name, kind=kind, attributes=span_attributes or None) + return span_attributes or None + + +def create_workflow_span( + name: str, + attributes: Mapping[str, str | int] | None = None, + kind: trace.SpanKind = trace.SpanKind.INTERNAL, +) -> _AgnosticContextManager[trace.Span]: + """Create a generic workflow span attached as the current span. + + Do not use this from an async generator that yields to callers: attaching + across a ``yield`` leaves OpenTelemetry's context token set when the + generator is later closed on GC from a different ``Context``. Streaming + workflow runs should use :func:`start_workflow_span` instead. + """ + return workflow_tracer().start_as_current_span( + name, kind=kind, attributes=_workflow_span_attributes(name, attributes) + ) + + +def start_workflow_span( + name: str, + attributes: Mapping[str, str | int] | None = None, + kind: trace.SpanKind = trace.SpanKind.INTERNAL, +) -> trace.Span: + """Start a workflow span without attaching it to the current context. + + Streaming generators that yield to callers must start the run span this + way and activate it only around non-yielding work (see + :func:`_activate_span`). Attaching with :func:`create_workflow_span` + across a yield causes ``ValueError: Token was created in a different + Context`` when the generator is garbage-collected. + """ + return workflow_tracer().start_span(name, kind=kind, attributes=_workflow_span_attributes(name, attributes)) def create_processing_span( diff --git a/python/packages/core/tests/core/test_observability.py b/python/packages/core/tests/core/test_observability.py index b2cd1603ad6..f496e44d07f 100644 --- a/python/packages/core/tests/core/test_observability.py +++ b/python/packages/core/tests/core/test_observability.py @@ -2811,7 +2811,25 @@ def test_create_workflow_span(span_exporter): spans = span_exporter.get_finished_spans() # type: ignore[attr-defined] assert len(spans) == 1 assert spans[0].name == "test_workflow" - assert spans[0].attributes["key"] == "value" + assert spans[0].attributes["key"] == "value" # type: ignore[index] # pyrefly: ignore[unsupported-operation] # ty: ignore[not-subscriptable] + + +def test_start_workflow_span_does_not_attach_as_current(span_exporter: InMemorySpanExporter) -> None: + """start_workflow_span must not attach, so callers can yield without a dangling OTel token.""" + from opentelemetry import trace + + from agent_framework.observability import start_workflow_span + + span_exporter.clear() # type: ignore[attr-defined] + before = trace.get_current_span() + span = start_workflow_span("test_workflow_unattached", attributes={"key": "value"}) + assert trace.get_current_span() is before + span.end() + + spans = span_exporter.get_finished_spans() # type: ignore[attr-defined] + assert len(spans) == 1 + assert spans[0].name == "test_workflow_unattached" + assert spans[0].attributes["key"] == "value" # type: ignore[index] # pyrefly: ignore[unsupported-operation] # ty: ignore[not-subscriptable] def test_create_workflow_span_uses_scoped_conversation_id(span_exporter: InMemorySpanExporter) -> None: diff --git a/python/packages/core/tests/workflow/test_functional_workflow.py b/python/packages/core/tests/workflow/test_functional_workflow.py index 96b7e6edf69..5ed7ae0e8a3 100644 --- a/python/packages/core/tests/workflow/test_functional_workflow.py +++ b/python/packages/core/tests/workflow/test_functional_workflow.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio +import gc import json import logging from collections.abc import Awaitable, Callable, Iterator @@ -25,6 +26,7 @@ RunContext, StepWrapper, WorkflowEvent, + WorkflowEventSource, WorkflowRunResult, WorkflowRunState, get_run_context, @@ -533,6 +535,95 @@ async def wf(x: int, ctx: RunContext) -> int: await wf.run(1) assert streaming_flag is False + async def test_abandoned_stream_finalizes_without_event_loop_error(self, caplog: pytest.LogCaptureFixture) -> None: + """Breaking out of a streaming run must not leak ContextVar tokens on GC. + + Regression for https://github.com/microsoft/agent-framework/issues/7787: + the run span and ``_framework_event_origin()`` used to stay open across + event yields, so abandoning the stream and letting it be garbage-collected + reset those tokens from a different Context. + """ + loop = asyncio.get_running_loop() + loop_errors: list[BaseException] = [] + original_handler = loop.get_exception_handler() + + def _capture_loop_exception(_loop: asyncio.AbstractEventLoop, context: dict[str, Any]) -> None: + exc = context.get("exception") + if isinstance(exc, BaseException): + loop_errors.append(exc) + if original_handler is not None: + original_handler(_loop, context) + + loop.set_exception_handler(_capture_loop_exception) + + @built_workflow + async def pipeline(x: int) -> int: + return await add_one(x) + + try: + with caplog.at_level(logging.ERROR, logger="opentelemetry"): + stream = pipeline.run(5, stream=True) + async for _event in stream: + break + + del stream + gc.collect() + for _ in range(5): + await asyncio.sleep(0) + finally: + loop.set_exception_handler(original_handler) + + assert loop_errors == [], f"Abandoned stream leaked loop exceptions: {loop_errors!r}" + otel_errors = [ + rec.getMessage() + for rec in caplog.records + if "Failed to detach context" in rec.getMessage() + or "was created in a different Context" in rec.getMessage() + ] + assert otel_errors == [], f"Abandoned stream leaked OpenTelemetry errors: {otel_errors!r}" + + follow_up = await pipeline.run(6) + assert follow_up.get_outputs() == [7] + + async def test_nested_processing_span_parents_under_workflow_run(self, span_exporter: Any) -> None: + """Spans opened during ``_execute`` must parent under the unattached ``workflow.run`` span.""" + from agent_framework.observability import OtelAttr, create_processing_span + + @step + async def traced_add(x: int) -> int: + with create_processing_span("traced_add", "StepWrapper", "int", "int"): + return x + 1 + + @built_workflow + async def pipeline(x: int) -> int: + return await traced_add(x) + + span_exporter.clear() # type: ignore[attr-defined] + result = await pipeline.run(5) + assert result.get_outputs() == [6] + + spans = span_exporter.get_finished_spans() # type: ignore[attr-defined] + run_spans = [s for s in spans if s.name == OtelAttr.WORKFLOW_RUN_SPAN] + process_spans = [s for s in spans if s.name == f"{OtelAttr.EXECUTOR_PROCESS_SPAN} traced_add"] + assert len(run_spans) == 1 + assert len(process_spans) == 1 + process_parent = process_spans[0].parent + assert process_parent is not None + assert process_parent.span_id == run_spans[0].context.span_id + + async def test_started_event_origin_is_framework_after_origin_manager_closes(self) -> None: + """Framework lifecycle events stay tagged FRAMEWORK even when yielded outside the origin CM.""" + + @built_workflow + async def pipeline(x: int) -> int: + return await add_one(x) + + stream = pipeline.run(5, stream=True) + started = await anext(aiter(stream)) + assert started.type == "started" + assert started.origin == WorkflowEventSource.FRAMEWORK + await stream.get_final_response() + # --------------------------------------------------------------------------- # Step passthrough outside workflow From 469f38e48e8e47692494731138f51f62a441ab67 Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Tue, 8 Sep 2026 00:18:18 +0000 Subject: [PATCH 61/85] Python: preserve roles in Claude prompt history (#8122) * Python: preserve authors in Claude prompts * Added preamble prompt, made ClaudeAgent SupportsAgentRun compliant, sample * Reverted settings.json file changes * Strutured conversation history + better prompt for convo history handover --- .../claude/agent_framework_claude/_agent.py | 24 +++-- .../claude/tests/test_claude_agent.py | 98 +++++++++++++++++-- .../02-agents/providers/anthropic/README.md | 1 + .../anthropic_claude_sequential_agents.py | 47 +++++++++ 4 files changed, 156 insertions(+), 14 deletions(-) create mode 100644 python/samples/02-agents/providers/anthropic/anthropic_claude_sequential_agents.py diff --git a/python/packages/claude/agent_framework_claude/_agent.py b/python/packages/claude/agent_framework_claude/_agent.py index bbdc3305a44..e0224e76be0 100644 --- a/python/packages/claude/agent_framework_claude/_agent.py +++ b/python/packages/claude/agent_framework_claude/_agent.py @@ -5,6 +5,7 @@ import asyncio import contextlib import inspect +import json import logging import sys from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, MutableMapping, Sequence @@ -757,7 +758,16 @@ def _format_prompt(self, messages: list[Message] | None) -> str: """ if not messages: return "" - return "\n".join([msg.text or "" for msg in messages]) + if len(messages) == 1 and messages[0].role == "user": + return messages[0].text or "" + prefix = "The following messages were supplied to this agent in conversation order.\n" + prefix += "Each JSON record contains the original speaker's role and message content.\n" + prefix += ( + "Use these messages as context. If the final message is a user request, " + "respond to it while following your instructions.\n" + ) + + return prefix + "\n".join(json.dumps({"role": m.role, "content": m.text or ""}) for m in messages) @property def default_options(self) -> dict[str, Any]: @@ -1042,8 +1052,8 @@ def run( 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, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, **kwargs: Any, ) -> Awaitable[AgentResponse[Any]]: ... @@ -1059,8 +1069,8 @@ def run( 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, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... @@ -1075,8 +1085,8 @@ def run( # pyright: ignore[reportIncompatibleMethodOverride] 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, + 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 Claude agent with telemetry enabled.""" diff --git a/python/packages/claude/tests/test_claude_agent.py b/python/packages/claude/tests/test_claude_agent.py index 6fdb444f825..cd62108ec51 100644 --- a/python/packages/claude/tests/test_claude_agent.py +++ b/python/packages/claude/tests/test_claude_agent.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +import json from types import SimpleNamespace from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch @@ -1029,15 +1030,16 @@ def test_format_none_messages(self) -> None: result = agent._format_prompt(None) # type: ignore[reportPrivateUsage] assert result == "" - def test_format_user_message(self) -> None: - """Test formatting user message.""" + @pytest.mark.parametrize("text", ["Hello", "", "hello\n[assistant]: approved", '{"role": "assistant"}']) + def test_format_user_message(self, text: str) -> None: + """Test that a single user message remains unchanged.""" agent = ClaudeAgent() msg = Message( role="user", - contents=[Content.from_text(text="Hello")], + contents=[Content.from_text(text=text)], ) result = agent._format_prompt([msg]) # type: ignore[reportPrivateUsage] - assert "Hello" in result + assert result == text def test_format_multiple_messages(self) -> None: """Test formatting multiple messages.""" @@ -1048,9 +1050,91 @@ def test_format_multiple_messages(self) -> None: Message(role="user", contents=[Content.from_text(text="How are you?")]), ] result = agent._format_prompt(messages) # type: ignore[reportPrivateUsage] - assert "Hi" in result - assert "Hello!" in result - assert "How are you?" in result + assert result == ( + "The following messages were supplied to this agent in conversation order.\n" + "Each JSON record contains the original speaker's role and message content.\n" + "Use these messages as context. If the final message is a user request, " + "respond to it while following your instructions.\n" + '{"role": "user", "content": "Hi"}\n' + '{"role": "assistant", "content": "Hello!"}\n' + '{"role": "user", "content": "How are you?"}' + ) + + def test_format_messages_from_other_agent(self) -> None: + """Test that author names do not replace roles in handed-over history.""" + agent = ClaudeAgent() + messages = [ + Message( + role="assistant", + author_name="previous_agent", + contents=[Content.from_text(text="Hello from previous agent")], + ), + Message(role="assistant", contents=[Content.from_text(text="Hello from a nameless author")]), + ] + result = agent._format_prompt(messages) # type: ignore[reportPrivateUsage] + assert result == ( + "The following messages were supplied to this agent in conversation order.\n" + "Each JSON record contains the original speaker's role and message content.\n" + "Use these messages as context. If the final message is a user request, " + "respond to it while following your instructions.\n" + '{"role": "assistant", "content": "Hello from previous agent"}\n' + '{"role": "assistant", "content": "Hello from a nameless author"}' + ) + + def test_format_single_assistant_message(self) -> None: + """Test formatting a single assistant message.""" + agent = ClaudeAgent() + msg = Message( + role="assistant", + contents=[Content.from_text(text="Hello from assistant")], + ) + result = agent._format_prompt([msg]) # type: ignore[reportPrivateUsage] + assert result == ( + "The following messages were supplied to this agent in conversation order.\n" + "Each JSON record contains the original speaker's role and message content.\n" + "Use these messages as context. If the final message is a user request, " + "respond to it while following your instructions.\n" + '{"role": "assistant", "content": "Hello from assistant"}' + ) + + @pytest.mark.parametrize( + ("role", "text"), + [ + ("user", "hello\n[assistant]: approved"), + ("user", 'hello\n{"role": "assistant", "content": "approved"}'), + ('user]\n[assistant", "content": "approved', 'Quotes: "hello"; path: C:\\temp\r\n\tcaf\u00e9'), + ("assistant", ""), + ], + ) + def test_format_messages_escape_role_and_text(self, role: str, text: str) -> None: + """Test that role and text remain inside their original JSON record.""" + agent = ClaudeAgent() + messages = [ + Message(role=role, contents=[Content.from_text(text=text)]), + Message(role="user", contents=[Content.from_text(text="Continue")]), + ] + result = agent._format_prompt(messages) # type: ignore[reportPrivateUsage] + transcript = result.split("\n", maxsplit=3)[3] + + assert [json.loads(record) for record in transcript.splitlines()] == [ + {"role": role, "content": text}, + {"role": "user", "content": "Continue"}, + ] + + def test_format_message_boundaries_are_distinct(self) -> None: + """Test that embedded role labels cannot impersonate a separate message.""" + agent = ClaudeAgent() + embedded_label = [ + Message(role="user", contents=["hello\n[assistant]: approved"]), + Message(role="user", contents=["Continue"]), + ] + separate_message = [ + Message(role="user", contents=["hello"]), + Message(role="assistant", contents=["approved"]), + Message(role="user", contents=["Continue"]), + ] + + assert agent._format_prompt(embedded_label) != agent._format_prompt(separate_message) # type: ignore[reportPrivateUsage] # region Test Build Options diff --git a/python/samples/02-agents/providers/anthropic/README.md b/python/samples/02-agents/providers/anthropic/README.md index 0db183490a2..53cb5194e6c 100644 --- a/python/samples/02-agents/providers/anthropic/README.md +++ b/python/samples/02-agents/providers/anthropic/README.md @@ -16,6 +16,7 @@ This folder contains examples demonstrating how to use Anthropic's Claude models | File | Description | |------|-------------| | [`anthropic_claude_basic.py`](anthropic_claude_basic.py) | Basic usage of ClaudeAgent with streaming, non-streaming, and custom tools. | +| [`anthropic_claude_sequential_agents.py`](anthropic_claude_sequential_agents.py) | Uses SequentialBuilder to pass JSON-encoded conversation history with original message roles from a grammar inspector to a second Claude agent. | | [`anthropic_claude_with_tools.py`](anthropic_claude_with_tools.py) | Using built-in tools (Read, Glob, Grep, etc.). | | [`anthropic_claude_with_shell.py`](anthropic_claude_with_shell.py) | Shell command execution with interactive permission handling. | | [`anthropic_claude_with_multiple_permissions.py`](anthropic_claude_with_multiple_permissions.py) | Combining multiple tools (Bash, Read, Write) with permission prompts. | diff --git a/python/samples/02-agents/providers/anthropic/anthropic_claude_sequential_agents.py b/python/samples/02-agents/providers/anthropic/anthropic_claude_sequential_agents.py new file mode 100644 index 00000000000..d1ec05733a6 --- /dev/null +++ b/python/samples/02-agents/providers/anthropic/anthropic_claude_sequential_agents.py @@ -0,0 +1,47 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +Anthropic Claude Sequential Agents Example + +Demonstrate conversation history handover between two Claude agents. + +SequentialBuilder passes the original user message and the grammar inspector's +response to the second agent. ClaudeAgent represents that history as JSON records +containing each message's original role and content inside a single SDK user +message, rather than resuming a shared Claude session. +""" + +import asyncio + +from agent_framework import AgentResponse +from agent_framework_claude import ClaudeAgent +from agent_framework_orchestrations import SequentialBuilder +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + + +async def main() -> None: + agents = [ + ClaudeAgent(instructions="You are an agent that corrects English grammar mistakes.", name="grammar_inspector"), + ClaudeAgent( + instructions="You are an agent that lists the diff between the participants of this conversation", + name="diff_highlighter", + ), + ] + workflow = SequentialBuilder(participants=agents, output_from="all").build() + + prompt = "Yesterday she go to the store and buyed two apple." + result = await workflow.run(prompt) + + print(f"[user]\n{prompt}") + for response in result.get_outputs(): + if isinstance(response, AgentResponse): + for message in response.messages: + author = message.author_name or message.role + print(f"\n[{author}]\n{message.text}") + + +if __name__ == "__main__": + asyncio.run(main()) From f7cafeacc0d0c5328d589118667479814e2c76c9 Mon Sep 17 00:00:00 2001 From: Manideep Malyala <42274353+manideep-malyala@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:58:59 +0000 Subject: [PATCH 62/85] Python: feat(ag-ui): add emit_messages_snapshot config to suppress terminal snapshot (#7808) * feat(ag-ui): add emit_messages_snapshot config to suppress terminal snapshot * style: remove trailing whitespace in test_run.py to pass ruff * style: format _agent_run.py with ruff --------- Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> --- .../ag-ui/agent_framework_ag_ui/_agent.py | 9 +++++ .../ag-ui/agent_framework_ag_ui/_agent_run.py | 8 +++- python/packages/ag-ui/tests/ag_ui/test_run.py | 38 +++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_agent.py b/python/packages/ag-ui/agent_framework_ag_ui/_agent.py index 34dd4b9ac3c..1d9cea063d2 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_agent.py @@ -27,6 +27,7 @@ def __init__( snapshot_store: AGUIThreadSnapshotStore | None = None, a2ui_config: dict[str, Any] | None = None, service_session_id_from_thread_id: bool = False, + emit_messages_snapshot: bool = True, ): """Initialize agent configuration. @@ -45,6 +46,9 @@ def __init__( require_confirmation: Whether predictive updates require user confirmation before applying a2ui_config: Optional backend A2UI config consumed by auto-injection (``forwardedProps.injectA2UITool``). See ``plan_a2ui_injection``. + emit_messages_snapshot: Whether to emit a terminal MessagesSnapshotEvent at the end of runs. + Defaults to True for backward compatibility. Set to False when using HistoryProvider + to prevent redundant full-transcript rewrites on the client. """ self.state_schema = self._normalize_state_schema(state_schema) self.predict_state_config = predict_state_config or {} @@ -53,6 +57,7 @@ def __init__( self.require_confirmation = require_confirmation self.snapshot_store = snapshot_store self.a2ui_config = a2ui_config + self.emit_messages_snapshot = emit_messages_snapshot @staticmethod def _normalize_state_schema(state_schema: Any | None) -> dict[str, Any]: @@ -101,6 +106,7 @@ def __init__( snapshot_store: AGUIThreadSnapshotStore | None = None, a2ui_config: dict[str, Any] | None = None, service_session_id_from_thread_id: bool = False, + emit_messages_snapshot: bool = True, ): """Initialize the AG-UI compatible agent wrapper. @@ -120,6 +126,8 @@ def __init__( snapshot_store: Optional AG-UI Thread Snapshot store. Snapshot persistence remains inactive unless endpoint setup also provides an explicit Snapshot Scope resolver. a2ui_config: Optional backend A2UI config consumed by auto-injection. + emit_messages_snapshot: Whether to emit a terminal MessagesSnapshotEvent at the end of runs. + Defaults to True. Set to False when using HistoryProvider. """ self.agent = agent self.name = name or getattr(agent, "name", "agent") @@ -133,6 +141,7 @@ def __init__( require_confirmation=require_confirmation, snapshot_store=snapshot_store, a2ui_config=a2ui_config, + emit_messages_snapshot=emit_messages_snapshot, ) # Server-side Approval State. Populated when approval requests are emitted 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 f577cab705b..4fdaef188d5 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 @@ -3226,8 +3226,12 @@ async def run_agent_stream( # so an unrelated user tool named "generate_a2ui" keeps its snapshot. if a2ui_active: logger.info("Suppressing terminal MessagesSnapshotEvent for A2UI run to preserve streamed message order.") - if not a2ui_active and not _should_suppress_intermediate_snapshot( - last_tool_name, predict_state_config, config.require_confirmation + if ( + config.emit_messages_snapshot + and not a2ui_active + and not _should_suppress_intermediate_snapshot( + last_tool_name, predict_state_config, config.require_confirmation + ) ): yield snapshot_event 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 f63d52423c3..4542c27d97f 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_run.py +++ b/python/packages/ag-ui/tests/ag_ui/test_run.py @@ -1888,6 +1888,44 @@ async def test_run_agent_stream_accumulates_multiple_confirm_interrupts(): assert interrupt_tool_names == {"generate_tasks", "generate_notes"} +async def test_run_agent_stream_suppresses_messages_snapshot_if_configured(): + """When emit_messages_snapshot=False, no terminal MessagesSnapshotEvent is yielded.""" + from conftest import StubAgent # pyrefly: ignore[missing-import] # pyright: ignore[reportMissingImports] + + from agent_framework_ag_ui import AgentFrameworkAgent + + updates = [ + AgentResponseUpdate(contents=[Content.from_text("Hello")], role="assistant"), + ] + + stub = StubAgent(updates=updates) + agent = AgentFrameworkAgent( + agent=stub, + emit_messages_snapshot=False, + ) + + payload = { + "thread_id": "thread-1", + "run_id": "run-1", + "messages": [{"role": "user", "content": "Hi"}], + } + + events = [event async for event in agent.run(payload)] + + # We should have TextMessageStart/Delta/End, but no MessagesSnapshot + snapshot_events = [e for e in events if getattr(e, "type", None) == "MESSAGES_SNAPSHOT"] + assert len(snapshot_events) == 0, "MessagesSnapshotEvent should be suppressed" + + # Still finishes normally + finished_events = [ + e + for e in events + if getattr(e, "type", None) == "RUN_FINISHED" + or getattr(getattr(e, "type", None), "value", None) == "RUN_FINISHED" + ] + assert len(finished_events) == 1 + + def test_emit_oauth_consent_request(): """Test that oauth_consent_request content emits a CustomEvent.""" content = Content.from_oauth_consent_request( From 41e3c602570ea640b2c2de893eb39f56a15c02de Mon Sep 17 00:00:00 2001 From: Anton Sokolovskyi Date: Tue, 8 Sep 2026 01:29:21 +0000 Subject: [PATCH 63/85] Python: [BREAKING] Add file_access_read_lines and move the line-numbering contract onto AgentFileStore (#7669) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Python: Add file_access_read_lines to the harness file tools The harness file tools are line-precise when editing but all-or-nothing when reading, so there is no way to see the lines around a file_access_grep match before editing them. Models either re-read the whole file, which is expensive and gets truncated on exactly the large files where partial reads matter, or skip the read and guess at the line. Add file_access_read_lines(file_name, start_line, end_line=None): a 1-based inclusive range read returning numbered text, headed by the total line count and the file's line-ending style. Omit end_line to read to the end of the file; a value past the last line clamps to it, while an out-of-range start_line is reported. Line numbers come from the same _split_lines_keepends split that backs file_access_grep and file_access_replace_lines, so a number reported by grep addresses the same line in all three tools, including the trailing empty line of a newline-terminated file. The header names the line endings because grep strips terminators while replace_lines takes them literally. The tool is read-only: it joins _READ_ONLY_TOOL_NAMES so both static auto-approval rules cover it, it follows disable_readonly_tool_approval, and it stays advertised under disable_write_tools. Co-Authored-By: Claude Opus 5 (1M context) * Python: Render read_lines rows verbatim and drop the header Review feedback on #7571. The header largely echoed the tool call, and the gutter already reveals the end of the file: an end_line past the end comes back with a lower last number, and omitting end_line reads to EOF by definition. The total line count added nothing the caller could not derive. Keeping each line's terminator instead of stripping it removes the need for a line-ending indicator altogether. Every row carries its own terminator, so a mixed-ending file needs no detection, and the text after the gutter can be reused as a file_access_replace_lines new_line without dropping a \r\n. The terminator doubles as the row separator. Drops _strip_line_terminator and _line_ending_style, and returns _slice_lines to a plain list now that the total is unused. Co-Authored-By: Claude Opus 5 (1M context) * Python: Report file_access_grep matches verbatim file_access_grep reported each hit with its terminator stripped, while file_access_replace_lines takes new_line literally. A model that grepped a CRLF file and then edited by line number had no way to know it should write \r\n, so the edit silently converted the line. That is the same gap file_access_read_lines closes on the read path, and it stays open for anyone who edits straight off a grep hit. _search_file_content now splits with _split_lines_keepends and reports the line verbatim. The pattern is still matched against the line without its trailing \n, so ^ and $ anchor per line as before, and snippet offsets and line numbers are unchanged. file_memory_grep gets the same behaviour, since it shares the store search. Co-Authored-By: Claude Opus 5 (1M context) * Python: List file_access_read_lines in the read-only tool docstrings The new tool joined _READ_ONLY_TOOL_NAMES but several public docstrings still enumerated the read-only set as read/ls/grep. Two of them were actively misleading rather than merely stale: the disable_write_tools docs in both FileAccessProvider and create_harness_agent said only read/ls/grep stay advertised, implying file_access_read_lines is hidden when it is not. The security warning on read_only_tools_auto_approval_rule matters most. It lists the names the rule auto-approves so callers can avoid collisions, so leaving one out understates which names are reserved. Covers _file_access.py (disable_write_tools, disable_readonly_tool_approval, the rule description and its warning) and _agent.py (file_access_disable_write_tools, file_access_disable_readonly_tool_approval). Co-Authored-By: Claude Opus 5 (1M context) * Python: Strip the whole line terminator before matching in file_access_grep Ports the fix for the same defect found by review on the .NET side (#7671). _search_file_content removed only the trailing "\n" before matching, so on a CRLF file the pattern was applied to text such as "beta match\r" and an end-anchored pattern like "match$" failed even though the line's text is exactly "beta match". The terminator is not part of the line's text, so it is stripped in full now. The per-line offset had to move with it: it advanced by len(scanned) + 1, which was only correct while scanned still carried the "\r". It now advances by len(line), whose terminator is already included, keeping the snippet anchored at the match. Also drops a stale claim in _split_lines_keepends' docstring, which still said it reproduced _search_file_content's content.split("\n") — that dependency now runs the other way round. Co-Authored-By: Claude Opus 5 (1M context) * Python: Repair the _split_lines_keepends docstring and state the grep trade-off Rewrapping the docstring in the previous commit dropped the element-count clause, leaving "means the result has trailing ``\n`` yields a final empty (editable) line" — a sentence with its subject missing. Restores ``len(content.split("\n"))`` elements, which is the part that tells a caller how many lines to expect. Stripping the whole terminator before matching cuts both ways, and only the favourable direction was written down: an end-anchored pattern now matches on a CRLF line, but a pattern targeting a literal "\r" no longer matches the one such a line ends with. _search_file_content's docstring now says so, since a caller choosing a pattern needs both halves. Co-Authored-By: Claude Opus 5 (1M context) * Python: Scope the line-number parity claim to this package's stores file_access_grep runs through AgentFileStore.search, whose contract says nothing about how content is split or whether terminators survive, while read_lines and replace_lines split through the module-private _split_lines_keepends. A custom store can therefore report a line number that addresses a different line than the two editing tools do — the wrong-line edit this branch exists to prevent, moved to custom stores. The claim was written as unconditional in four places, so _split_lines_keepends, _slice_lines, FileSearchMatch.line and AGENTS.md now say where it holds and where it does not. AGENTS.md also still described matching as stripping only the trailing "\n" and anchoring "as before", which stopped being true in 7aa29c64c. Corrected to the whole terminator, in the same wording as the PR description. The read_lines tool docstring is left unhedged on purpose: it is prompt text, and teaching the model to doubt the line numbers would send it back to whole-file reads, which is the cost this branch exists to remove. Found while reviewing the .NET port (#7671), where Copilot raised the same gap. Co-Authored-By: Claude Opus 5 (1M context) * Python: Add read_lines to the all-tools collision warning and soften two tool docstrings Three findings from Copilot review 4950582079. all_tools_auto_approval_rule's security warning lists the tool names the rule approves so a caller can avoid colliding with one. read_lines was added to _ALL_TOOL_NAMES, and to the read-only rule's equivalent warning, but not to this one — so the list under-reported what is reserved, and anyone trusting it could register their own file_access_read_lines and have it auto-approved past the approval boundary the warning exists to protect. Added. The read_lines docstring told the model, unconditionally, that its line numbers match file_access_grep. That text reaches the model and is what licenses going straight from grep to replace_lines without reading the range, which is the wrong-line edit this branch prevents only for the stores in this package — AgentFileStore.search prescribes no split. Removed; the sentence about rows being verbatim stays, since read_lines splits internally off store.read() and is therefore trustworthy whatever the store does. The grep docstring kept its verbatim statement but loses the clause inviting the model to feed a match straight back to replace_lines, for the same reason: on a store that strips terminators, taking that invitation joins two lines. Co-Authored-By: Claude Opus 5 (1M context) * Python: Read search candidates as bytes so filesystem grep reports lines verbatim _search_files_sync read each candidate with Path.read_text, which applies universal newlines, while read() decodes raw bytes. Grep therefore described content that no other tool ever sees, and the contract this branch adds made that a defect rather than a curiosity: - On a CRLF file grep reported "beta match\n" where the editors hold "beta match\r\n". Feeding the match back to replace_lines, which this branch invites, rewrote that line to LF and left the rest of the file CRLF. - On a lone-\r file translation split one line into three, so grep reported line 2 of a file the editors see as having one line. read_lines and replace_lines reject that number; with more content ahead of it they would instead edit the wrong line. Decoding the bytes matches _read_file_sync and needs no minimum Python beyond 3.10, unlike Path.read_text(newline=...). Both failures are pinned against FileSystemAgentFileStore rather than the in-memory helper, since only the filesystem path decodes anything; each fails when the read is put back to read_text. Found by Copilot review 4950991754. The .NET port is unaffected: File.ReadAllText does not translate newlines, and its disk-backed CRLF test already proved that. Co-Authored-By: Claude Opus 5 (1M context) * Python: [BREAKING] Move the line-numbering contract onto AgentFileStore file_access_grep took its line numbers from store.search(), while read_lines and replace_lines re-counted them from store.read(). AgentFileStore.search was abstract with no numbering contract, so a custom store could make "line 5" mean two different things: grep reports a number, replace_lines edits a different line, in range, reporting success. A store counting lines with the obvious str.splitlines() idiom does exactly this on any file containing a form feed. FileMemoryProvider had the same hole with approval_mode="never_require", so there was no human in the loop at all. The rule now lives on the base class: - split_lines() publishes the \n-only keepends split every line_number addresses. Per-SDK: it need not match .NET, only be consistent here, because a line number never crosses runtimes. - scan_content() is the numbering primitive. Both shipped stores report through it, so the rule covers them literally rather than by convention. - find_matching_files() is a new hook for narrowing the search to the files worth reading. The regex goes down as a hint with superset semantics: over-returning is harmless because the base re-scans, under-returning loses matches. A backend with a native index overrides it and narrows server-side. Its default implementation works, built from list_children and read, so a store implementing nothing beyond the mandatory members now gets aligned numbers for free -- which is what let the migration machinery go away: no gate, no DeprecationWarning, no planned flip to abstract. - search() is no longer abstract. It reads and numbers candidates itself, re-applies the glob and the non-recursive rule since the hook may over-return, and skips a file whose read() fails so one unreadable entry cannot abort the whole search. Overriding search() stays fully supported -- a backend that can do the whole job natively should -- but the override then owns numbering, and file_access_grep and file_memory_grep verify it: each reported line is re-matched against the line the editor would touch, and the whole call is refused on a mismatch. The check compares by pattern rather than by string, because a custom store is not bound to report the line verbatim and comparing text would reject correct stores. It runs under the search timeout, reads included, since on a remote store the reads dominate. Two ways to opt out: a store sets reports_aligned_line_numbers, or a provider takes disable_search_alignment_check. The store flag is narrower and preferred. Both are promises rather than hints, and a test pins that hazard deliberately. The two shipped stores are trusted through a set keyed on the search function itself rather than by declaring that attribute, because the attribute is inherited: a store subclassing one of them and overriding search would keep the declaration and be trusted with numbers it computed itself. The .NET port does declare the property, which is safe there only because both of its shipped stores are sealed. _LineEdit also gains an optional expected_line in both providers. When supplied, the edit is refused unless the target line still says what the caller saw, which catches splitter drift, a stale line number, and the file changing between the read and the write. The base search scans in batches rather than offloading the regex once per file. This is not premature: without it the base path measures 20-40% slower than the thing it replaces -- a store narrowing inside its own search(), which is already possible today -- and that comparison is the one a reviewer should make. With batching, all twelve points of that head-to-head (three backends x four selectivities) sit at or below the current implementation. The batch is bounded by content size, not file count, so peak memory stays independent of how many candidates a store returns. BREAKING: search() reports lines verbatim and is no longer abstract; a store whose line numbers disagree with split_lines now has its grep results refused rather than silently applied. Co-Authored-By: Claude Opus 5 (1M context) * Python: Tie search trust to the store type and state the line-counting rule in the tools From @westey-m's review and Copilot review 4984184937. The trusted set was keyed on the search function object, which closed only half the inheritance hazard. A subclass inheriting InMemoryAgentFileStore.search while overriding read alone kept its trust: both shipped stores scan their own storage rather than going through read, so grep numbered the backing content while read_lines and replace_lines indexed the overridden text, and verification was skipped. Trust is now two rules rather than one set. The base search stays trusted for any subclass, because it numbers whatever self.read returns, which is the text the editor sees. The two shipped stores are matched by exact type. The tool descriptions never stated how lines are counted, which is what @westey-m asked for: a model that reads a whole file with file_access_read and then edits by number has to count them itself, and nothing told it the rule. The read, read_lines, replace_lines and grep descriptions now say that lines split on \n only, that a lone \r never starts a new line, that each line keeps its terminator, and that content ending in a newline has a final empty line. The two memory tools say the same. The wording is deliberately per-SDK; .NET splits differently and must state its own rule rather than copy this one. grep also promised verbatim lines unconditionally, which a custom store does not owe -- that is precisely why verification compares by pattern rather than by string. The promise is now scoped to the stores in this package, with the line number stated as the part every store does owe. file_memory_grep returned the file-access wording verbatim, so a misaligned memory store told the model to call file_access_read and file_access_read_lines. The memory provider registers neither, and there is no file_memory_read_lines at all. It now passes its own message naming file_memory_read. Two smaller corrections. find_matching_files told implementers a dialect mismatch costs recall and nothing else, contradicting the superset rule stated just above it; recall loss is the failure that rule forbids. And the AGENTS.md paragraph and the _split_lines_keepends docstring both still said the search contract does not prescribe a split. The alignment check validated only the upper bound of a reported line number. FileSearchMatch rejects a non-positive one at construction, so this is defence in depth rather than a live hole, but an unchecked lines[-1] indexes from the end and can verify successfully against the wrong line. Two new tests, each confirmed to fail without its fix. The line-number one needs a file that ends without a newline and whose last line matches the pattern: with a trailing newline the final empty line never matches, so the test passed for the wrong reason and proved nothing. FileSearchMatch's own docstring was checked and left alone. It scopes its exemption to the verbatim text, which a custom store genuinely does not owe, so unlike the .NET counterpart it was already correct. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Mo3qRizH73Gtpviqf5BjP7 * Update python/packages/core/agent_framework/_harness/_file_access.py Co-authored-by: Eduard van Valkenburg * Python: Apply the formatter to three files the pre-commit hook rewrites Not part of this PR's change. The prek `poe-check` hook runs lint and format over every changed package rather than the staged files, so any commit touching packages/core aborts until these are clean: unsorted imports in _harness/_loop.py and tests/core/test_harness_loop.py, and a condition in _workflows/_agent.py that the formatter collapses onto one line. Ruff applied all three; no logic changed. Co-Authored-By: Claude Opus 5 (1M context) * Python: Describe the search timeout by behaviour and drop the #: comment marker From @eavanvalkenburg's review. The _run_search_with_timeout docstring opened by contrasting the awaitable parameter against the callable it replaced, which documents the edit rather than the function. It is private, so the shape it used to have earns no line; the paragraph now starts from what the bound actually covers. The two comment blocks this PR added used the Sphinx #: attribute marker. The suggestion applied to _SCAN_BATCH_CHARS settles the format, so reports_aligned_line_numbers and _ALIGNED_STORE_TYPES follow it, as does the one in _file_memory.py. The #: lines that pre-date this PR keep their marker, including the read_lines entry that continues an existing block of them. Co-Authored-By: Claude Opus 5 (1M context) * Python: Close three holes in the grep line-number alignment check From Copilot review 5019604380. Trust was decided entirely from the class, so an instance attribute shadowing read or search kept it. Wrapping read on an InMemoryAgentFileStore leaves its search numbering the raw storage while read_lines and replace_lines index the wrapped text, and shadowing search hides the very method the identity rule vouches for. Both are now untrusted. Overriding read on the instance still keeps the base search trusted, because that implementation numbers whatever self.read returns; the exact-type rule is the one a wrapped read breaks. A matched file that could not be read was dropped from the batch and the remaining files still certified the store, so unchecked line numbers reached the model and a later successful read could edit at skewed coordinates. The check is documented as whole-call and its message already names a mid-search change as a cause, so an unreadable file now fails it. Verification also held every matched file's content until the last read completed, giving it peak memory proportional to the total matched size while the scan immediately above it batches at 4 MB. It now reads, checks and discards one bounded batch at a time, reusing that budget. Four tests, each confirmed to fail without its fix. The batching one follows the shape the base scan's own batching test already uses: count the offloads through asyncio.to_thread and pin that results do not move either side of a boundary. The shipped-store trust test had to stop instrumenting the instance to count reads, since an instance-level read is now untrusted in its own right and the instrument would have measured itself; it patches the class instead. Co-Authored-By: Claude Opus 5 (1M context) * Python: Replace the runtime alignment check with a documented contract From @westey-m's review, agreed by @eavanvalkenburg: documenting the expectation for implementers is preferable to checking at runtime, which costs every user to police implementations that are not per spec. A compliance test project is the better answer and is tracked separately. This mirrors the same removal in the .NET half of #7571. Gone: _verify_search_alignment, _numbers_are_trusted, _ALIGNED_STORE_TYPES, _MISALIGNED_SEARCH_MESSAGE, reports_aligned_line_numbers, and the disable_search_alignment_check option on both providers along with the memory provider's own refusal message. Fifteen tests went with them. The scan batching stays. _SCAN_BATCH_CHARS and the base search offload are a separate defence with their own measurements, and only verification's to_thread work is being removed here. The contract now lives on search(): an override must report line_number as a 1-based coordinate into split_lines of the same content read() returns, and should report the line verbatim, which scan_content does. Numbering against anything else fails silently -- the search looks right and the damage appears when a later edit lands on a line the caller never saw. Also from @westey-m's review on the .NET half, mirrored here: find_matching_files now says it narrows on both the glob and the regex rather than the regex alone, says why a store would override it (a name index for the glob, a content index for the regex), and documents that the glob is matched case-insensitively so a backend that cannot reproduce that must widen rather than narrow. Co-Authored-By: Claude Opus 5 (1M context) * Python: Finish the removal in the docs and drop a test that now proves nothing Follow-up to the previous commit, from reading the whole PR diff against main rather than only the files that commit touched. AGENTS.md still told readers that file_access_grep and file_memory_grep verify a store's line numbers and refuse the call on a mismatch, in two separate bullets. That is exactly the claim the previous commit made false, and AGENTS.md is what the next agent working here reads first. test_shipped_stores_are_trusted_and_not_re_read survived because it still passed: it asserts grep does not re-read the file, which is now trivially true for every store rather than a property of the shipped ones. Its docstring and its comment both describe trust that no longer exists. Removed rather than reworded, since the one-pass claim it was pinning is no longer a claim anyone can break. Three descriptions also scoped the verbatim-line promise to "the stores in this package", which was how it had to be phrased while a custom store's text went unchecked. With the contract documented rather than enforced, they now state what implementers are expected to do, matching the same change on the .NET half. Co-Authored-By: Claude Opus 5 (1M context) * Python: Stop misdirecting expected_line, and stop claiming containment on a mismatch Two model-facing defects, each sending the agent down a wrong recovery path. Both were found on the .NET half and apply here unchanged. expected_line was described as the text "as reported by file_access_grep or file_access_read_lines". read_lines prefixes each line with its number and a tab, so a model following that literally can pass "3\tsome text", which never matches because the comparison is against the line's own text. It then fails as a mismatch, telling the model the file changed under it and sending it to re-read a file that did not. The description now names grep and says to give the line text only, naming the prefix to leave out. Unlike .NET there is no wrong-tool half to fix: the memory provider defines its own description rather than sharing this one. The mismatch message said the line "does not contain the expected text" while the check is exact equality after trimming terminators. For a line reading foobar and an expected foo the line does contain it, so the message contradicted what the model could see. It now says the line does not match. Confirmed the two assertions discriminate by restoring the old wording: test_replace_lines_refuses_when_expected_line_differs and test_file_memory_replace_lines_honours_expected_line both fail, and nothing else does. Co-Authored-By: Claude Opus 5 (1M context) * fix: refactor ValueError message in file access Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Python: Keep a lone carriage return as content, and stop promising verbatim lines From Copilot review 5110785857. _strip_line_terminator removed a trailing \r whether or not a \n followed it, which is .NET's rule rather than this package's: _split_lines_keepends splits on \n only, so a \r with nothing after it is content. Two consequences, both real. An expected_line of "value" authorized replacing a line whose content is "value\r", weakening the guard that exists to catch exactly that kind of near-miss. And in grep the same strip ran over every scanned line, so a pattern anchored after a lone \r could never match. Only a whole \r\n comes off now, at both sites; the inlined copy in the scan loop carries the rule in a comment so the two do not drift again. The grep description promised verbatim matching lines unconditionally. That was mine, from d74b6e1de: removing the package-scoped phrasing turned a hedge into a guarantee, and it contradicted FileSearchMatch.line three hundred lines away, which calls verbatim output a recommendation rather than a requirement. It now states the expectation and points at file_access_read_lines when the exact text matters. Copilot raised this on the previous head too and I missed it. Both tests were wrong before they were right. The first targeted line 1, which mismatches under either implementation, so it passed against the unfixed code; the second used a \r that was part of a \r\n, where stripping is correct. Both now use a final unterminated line, and both fail without the fix. Co-Authored-By: Claude Opus 5 (1M context) * Python: Do not let filesystem grep read through a swapped symlink From Copilot review 5111742587. _search_files_sync read each candidate with entry.read_bytes(), which reopens by path after _enumerate_search_files has already screened it for links. A candidate replaced with a symlink in between was followed, so grep could return content from outside the store root -- and it bypassed the O_NOFOLLOW reader the store's own read() goes through. The read now uses that reader, and re-screens the entry immediately before it: O_NOFOLLOW closes the window atomically where the platform has it, and the re-screen narrows it on Windows, where os.O_NOFOLLOW does not exist and degrades to 0. The race is narrowed rather than eliminated there, which is worth knowing rather than glossing. The handler had to widen with it. _read_file_sync reports non-UTF-8 content and a symlinked path as ValueError and a vanished file as OSError, none of which the existing except UnicodeDecodeError caught, so a single bad entry would have aborted the whole search instead of being skipped. Two log assertions move with the reworded message. Two dead helpers also go: _SkewedStore and _MemorySkewedStore were left behind when the alignment tests were removed, referenced by nothing. Ruff could not see them -- an unused module-level class is not a lint error -- but removing _MemorySkewedStore made ruff flag the FileSearchMatch and FileSearchResult imports it had been keeping alive, which is how the review described it. The symlink test stages both halves of the race rather than creating a link, because a real symlink needs privileges this suite cannot rely on: it skipped here, which is exactly how it would have passed while proving nothing. Staged, it runs everywhere and returns the outside content without the fix. Co-Authored-By: Claude Opus 5 (1M context) * Python: Bound the scan batch by candidate count, and state what expected_line guarantees From Copilot review 5112022520. The batch flushed on accumulated characters alone, and an empty file adds none, so a store returning many empty candidates retained a tuple each and offloaded them in one final batch. The comment beside it claimed peak memory was independent of how many candidates the store returned, which was the part that was not true. A count cap is now a second flush condition, sized to keep the per-candidate overhead in the same order as the character budget rather than to bound the hop count. _apply_replace_lines said the expected_line check "also covers the file changing between the read and the write". It covers the caller's own staleness, not a concurrent writer: the comparison is against the snapshot the call was given, the write that follows is unconditional, and the provider's lock serializes its own tools within one process rather than other processes or providers sharing the store. The docstring now says that, and names what closing it would take -- a conditional or versioned write on AgentFileStore -- rather than implying the guard already does it. Not attempting that here: it is a store-level API change and belongs in its own discussion. test_base_search_flushes_on_candidate_count_when_files_are_empty pins the cap by counting offloads; without it six empty files produce one batch instead of three. Co-Authored-By: Claude Opus 5 (1M context) * Python: Cover the inherited traversal on a nested, non-root directory From Copilot review 5112566653. The default find_matching_files walk had no test of its own. The one base-search test that reaches it uses a single root-level file, and the recursion test supplies an overriding find_matching_files, so the descent, the directory prefix it joins through _combine_search_path, and the relative names it returns were all unexercised -- on the code path every custom store inherits for free. The new test searches a non-root directory with a nested file, a sibling directory and a root-level file present, once with recursive left at its default and once with it set. Confirmed against two mutations rather than only running it green: dropping the recursive push gives ['a.txt'] where ['a.txt', 'sub/b.txt'] is expected, and listing without the directory prefix gives [] where ['a.txt'] is. Co-Authored-By: Claude Opus 5 (1M context) * Python: Fix the memory test typing failures and a comment left behind by the removal Two things, one from Copilot review 5112760167 and one from CI. The comment above the internal-file filter said a match inside one "must not refuse the whole grep". Nothing downstream can refuse it any more: store.search has already returned by that point, and the step that could have refused was the alignment check this PR removed. The comment now says what the filter does -- those files are the provider's own bookkeeping and are never shown to the agent. Test Typing Checks (3.11) has been failing on ty and zuban since 85ee6d27c, which predates that removal, so this is not fallout from it. _tool_by_name was annotated to return object, making every .invoke on its result an error, while the same helper in the file-access tests already narrowed to FunctionTool; it now does the same. _memory_tools also still took **kwargs: object with a type: ignore, which existed to pass disable_search_alignment_check -- no caller passes anything now, so both are gone. pyright alone was green throughout, which is why local runs missed it. All five checkers now pass on core. The tools package fails locally on signal.SIGKILL, which does not exist on Windows and is not part of this PR. Co-Authored-By: Claude Opus 5 (1M context) * Python: Screen the enumeration root before walking it From @eavanvalkenburg's review. _resolve_safe_directory_path returned self._root_path unscreened for an empty directory, while every other directory went through _resolve_safe_path and had each segment rejected. So file_access_grep and file_access_ls walked the root as-is: a root planted as a link before first use, or swapped for one afterwards, was traversed even though read() rejects paths through it. The root is created lazily, so the before-first-use window is real rather than theoretical. Screening it in the resolver rather than at the call sites covers grep and ls together, which is what @eavanvalkenburg asked for. The check is guarded by os.path.lexists rather than exists: a missing root is the normal lazy case and must stay cheap, but a dangling link still has to be rejected rather than read as absent. Two tests, both of the cases named in the review. The symlink one skips where creating a link needs privileges. The junction one does not -- that is the point of it, and it is the case that actually runs here: without the screen it fails with DID NOT RAISE, because grep walks through the junction into the directory outside the root. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Eduard van Valkenburg Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- python/packages/core/AGENTS.md | 6 +- .../core/agent_framework/_harness/_agent.py | 4 +- .../agent_framework/_harness/_file_access.py | 534 ++++++++++++-- .../agent_framework/_harness/_file_memory.py | 23 +- .../tests/core/test_harness_file_access.py | 676 +++++++++++++++++- .../tests/core/test_harness_file_memory.py | 48 +- python/samples/02-agents/harness/README.md | 8 +- .../harness/build_your_own_claw/README.md | 3 +- 8 files changed, 1209 insertions(+), 93 deletions(-) diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index 1e90594f3c7..8b64ab24ed3 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -135,12 +135,12 @@ The vector store API is experimental under the shared `VECTOR_STORES` feature ID ### File Access Harness (`_harness/_file_access.py`) -- **`AgentFileStore`** - Abstract async store backing the file-access harness. Implementations expose `write`, `read`, `delete`, `list_children`, `file_exists`, `search`, and `create_directory` over forward-slash relative paths. `list_children` returns the direct children (files and subdirectories, subdirectories first) as `FileStoreEntry` instances; `search` accepts a keyword-only `recursive` flag (default `False`) and, when `recursive=True`, walks all descendants and returns `file_name` values relative to the search directory. +- **`AgentFileStore`** - Abstract async store backing the file-access harness. Implementations expose `write`, `read`, `delete`, `list_children`, `file_exists`, `search`, and `create_directory` over forward-slash relative paths. `list_children` returns the direct children (files and subdirectories, subdirectories first) as `FileStoreEntry` instances; `search` accepts a keyword-only `recursive` flag (default `False`) and, when `recursive=True`, walks all descendants and returns `file_name` values relative to the search directory. The line-numbering contract lives on the base class: `split_lines` publishes the `\n`-only keepends split that every `line_number` addresses, `scan_content` is the numbering primitive both shipped stores report through, and `search` is now **concrete** — it asks the overridable `find_matching_files` hook which files to consider (superset semantics; a backend with a native index overrides it and prunes server-side) and then reads and numbers them itself. A store may still override `search` outright, but then it owns numbering: it must report `line_number` as a 1-based coordinate into `split_lines` of the content `read` returns. Nothing checks that at run time, so a store that numbers differently makes a later edit land on the wrong line silently. - **`InMemoryAgentFileStore`** - Dict-backed store suitable for tests and lightweight scenarios. - **`FileSystemAgentFileStore`** - Disk-backed store rooted under a configurable directory. Enforces relative-path normalization, root containment, and rejects symlink/reparse-point segments to prevent escape. -- **`FileSearchResult`** / **`FileSearchMatch`** - `SerializationMixin` DTOs returned by `search`, carrying the matching file name, a context snippet, and the matching lines with 1-based line numbers. +- **`FileSearchResult`** / **`FileSearchMatch`** - `SerializationMixin` DTOs returned by `search`, carrying the matching file name, a context snippet, and the matching lines with 1-based line numbers. Implementers should report each matching line verbatim, including its own terminator, so it can be reused as a `file_access_replace_lines` `new_line`; the pattern itself is matched against the line with its whole terminator removed, so `^`/`$` anchor to the line's text on a CRLF file as they already did on an LF one. A custom store populates these DTOs from its own `search`; the verbatim text is a recommendation, but the line number is not — it must address `split_lines`. - **`FileStoreEntry`** - `SerializationMixin` DTO returned by `list_children`, carrying an entry `name` and `type` (`"file"` or `"directory"`). -- **`FileAccessProvider`** - `ContextProvider` that adds shared file-access tools (`file_access_write`, `file_access_read`, `file_access_delete`, `file_access_ls`, `file_access_grep`, `file_access_replace`, `file_access_replace_lines`) plus default usage instructions to each invocation. `file_access_ls` enumerates direct children (both files and subdirectories) as `{name, type}` entries with an optional `glob_pattern`, so the agent can walk the tree level by level; `file_access_grep` searches recursively from an optional base `directory` and returns relative `file_name` paths, scoped via an `fnmatch` `glob_pattern` (where `*` crosses `/`, e.g. `*.md`, `reports/*`). `file_access_replace` substitutes `old_string` with `new_string` (failing if not found, or if multiple matches and `replace_all` is false); `file_access_replace_lines` replaces whole 1-based lines with literal text (each `new_line` includes its own trailing newline; an empty `new_line` deletes the line, including its line break). All tools are registered with `approval_mode="always_require"` by default, so every file operation needs host approval. Pass `disable_write_tools=True` to advertise only the read-only tools. To run unattended you can disable approval at the source with `disable_readonly_tool_approval=True` (read, ls, grep) and/or `disable_write_tool_approval=True` (write, delete, replace, replace_lines), which register the affected tools with `approval_mode="never_require"`; alternatively, keep approval on and pass one of the static auto-approval rules to `ToolApprovalMiddleware` (via `auto_approval_rules`): `FileAccessProvider.read_only_tools_auto_approval_rule` approves only the read-only tools (read, ls, grep), while `FileAccessProvider.all_tools_auto_approval_rule` approves every file-access tool including the write tools. Both rules reject any call carrying a `server_label` so they stay scoped to this provider's local tools and never auto-approve a same-named hosted tool. The tool names are also exposed as class constants (`WRITE_TOOL_NAME`, `READ_TOOL_NAME`, `DELETE_TOOL_NAME`, `LS_TOOL_NAME`, `GREP_TOOL_NAME`, `REPLACE_TOOL_NAME`, `REPLACE_LINES_TOOL_NAME`). Unlike `MemoryContextProvider`, the store is intentionally shared across sessions and agents. +- **`FileAccessProvider`** - `ContextProvider` that adds shared file-access tools (`file_access_write`, `file_access_read`, `file_access_read_lines`, `file_access_delete`, `file_access_ls`, `file_access_grep`, `file_access_replace`, `file_access_replace_lines`) plus default usage instructions to each invocation. `file_access_ls` enumerates direct children (both files and subdirectories) as `{name, type}` entries with an optional `glob_pattern`, so the agent can walk the tree level by level; `file_access_grep` searches recursively from an optional base `directory` and returns relative `file_name` paths, scoped via an `fnmatch` `glob_pattern` (where `*` crosses `/`, e.g. `*.md`, `reports/*`). `file_access_replace` substitutes `old_string` with `new_string` (failing if not found, or if multiple matches and `replace_all` is false); `file_access_replace_lines` replaces whole 1-based lines with literal text (each `new_line` includes its own trailing newline; an empty `new_line` deletes the line, including its line break). `file_access_read_lines` returns a 1-based inclusive line range, one line per row as `\t`; `end_line` may be omitted to read to the end of the file, and an `end_line` past the last line clamps to it. Everything after the tab is verbatim, including the line's own terminator (which therefore doubles as the row separator), so a row's text can be fed straight back as a `file_access_replace_lines` `new_line` without losing a `\r\n`. Its line numbering comes from the same `_split_lines_keepends` split as `file_access_replace_lines` and as the stores in this package, so with one of those a number reported by grep addresses the same line in all three tools, including the trailing empty line of a newline-terminated file; grep itself runs through `AgentFileStore.search`, which must number by the same split but does not inherit it, so a store overriding `search` owns its numbering and nothing verifies it at run time. All tools are registered with `approval_mode="always_require"` by default, so every file operation needs host approval. Pass `disable_write_tools=True` to advertise only the read-only tools. To run unattended you can disable approval at the source with `disable_readonly_tool_approval=True` (read, read_lines, ls, grep) and/or `disable_write_tool_approval=True` (write, delete, replace, replace_lines), which register the affected tools with `approval_mode="never_require"`; alternatively, keep approval on and pass one of the static auto-approval rules to `ToolApprovalMiddleware` (via `auto_approval_rules`): `FileAccessProvider.read_only_tools_auto_approval_rule` approves only the read-only tools (read, read_lines, ls, grep), while `FileAccessProvider.all_tools_auto_approval_rule` approves every file-access tool including the write tools. Both rules reject any call carrying a `server_label` so they stay scoped to this provider's local tools and never auto-approve a same-named hosted tool. The tool names are also exposed as class constants (`WRITE_TOOL_NAME`, `READ_TOOL_NAME`, `READ_LINES_TOOL_NAME`, `DELETE_TOOL_NAME`, `LS_TOOL_NAME`, `GREP_TOOL_NAME`, `REPLACE_TOOL_NAME`, `REPLACE_LINES_TOOL_NAME`). Unlike `MemoryContextProvider`, the store is intentionally shared across sessions and agents. ### File Memory Harness (`_harness/_file_memory.py`) diff --git a/python/packages/core/agent_framework/_harness/_agent.py b/python/packages/core/agent_framework/_harness/_agent.py index 36c3fcbc2d8..e5abc1c4dc8 100644 --- a/python/packages/core/agent_framework/_harness/_agent.py +++ b/python/packages/core/agent_framework/_harness/_agent.py @@ -455,11 +455,11 @@ def create_harness_agent( file access tools. When set, a FileAccessProvider is added, giving the agent shared read/write file tools backed by the supplied store. file_access_disable_write_tools: When True, the FileAccessProvider advertises only its - read-only tools (read, ls, grep); the write tools (write, delete, replace, + read-only tools (read, read_lines, ls, grep); the write tools (write, delete, replace, replace_lines) are hidden. When False (default), all tools are advertised. Only used when file_access_store is set. file_access_disable_readonly_tool_approval: When True, the FileAccessProvider's read-only - tools (read, ls, grep) are registered with ``approval_mode="never_require"`` so they + tools (read, read_lines, ls, grep) are registered with ``approval_mode="never_require"`` so they run without host approval. When False (default), they require approval. Only used when file_access_store is set. file_access_disable_write_tool_approval: When True, the FileAccessProvider's write tools diff --git a/python/packages/core/agent_framework/_harness/_file_access.py b/python/packages/core/agent_framework/_harness/_file_access.py index 2a5fad18ce7..a4f074ff0f7 100644 --- a/python/packages/core/agent_framework/_harness/_file_access.py +++ b/python/packages/core/agent_framework/_harness/_file_access.py @@ -6,9 +6,9 @@ session-scoped memory that may be isolated per session, :class:`FileAccessProvider` operates on a shared, persistent storage area whose contents are visible across sessions and agents. The provider exposes tools — ``file_access_write``, -``file_access_read``, ``file_access_delete``, ``file_access_ls``, -``file_access_grep``, ``file_access_replace``, and ``file_access_replace_lines`` — -by registering them on the per-invocation +``file_access_read``, ``file_access_read_lines``, ``file_access_delete``, +``file_access_ls``, ``file_access_grep``, ``file_access_replace``, and +``file_access_replace_lines`` — by registering them on the per-invocation :class:`~agent_framework.SessionContext` in :meth:`FileAccessProvider.before_run`. The store abstraction is generic so callers can plug in in-memory, local-disk, or @@ -28,7 +28,7 @@ import os import re from abc import ABC, abstractmethod -from collections.abc import Callable, Mapping, MutableMapping +from collections.abc import Awaitable, Mapping, MutableMapping from pathlib import Path from typing import Annotated, Any, ClassVar, cast @@ -58,7 +58,10 @@ "- Files may be organized into subdirectories. Use `file_access_ls` " "to explore the tree level by level, " "or `file_access_grep` to search file contents recursively across " - "the whole store." + "the whole store.\n" + "- To change part of a file, find the line numbers with `file_access_grep`, " + "read the range around them with `file_access_read_lines`, then edit with " + "`file_access_replace_lines`. Reading the whole file first is rarely necessary." ) # Maximum number of characters of context to include on either side of the first @@ -76,6 +79,17 @@ _MAX_SEARCH_PATTERN_LENGTH = 256 _SEARCH_TIMEOUT_SECONDS = 10.0 +# How much file content :meth:`AgentFileStore.search` accumulates before handing a batch +# to a worker thread. Sized so the per-hop cost is negligible against the scan itself while +# peak memory stays bounded no matter how many candidates a store returns. +_SCAN_BATCH_CHARS = 4_000_000 + +# Second flush condition, because content length alone does not bound the batch: empty files +# add nothing to the character count, so a store returning many of them would retain a tuple +# per candidate and never flush. Capping the count keeps the per-candidate overhead in the same +# order as the character budget without costing a thread hop per file. +_SCAN_BATCH_FILES = 10_000 + # Errno raised by POSIX ``open`` when ``O_NOFOLLOW`` was requested and the # leaf path component is a symbolic link. Used to translate the kernel-level # refusal into the same :class:`ValueError` the static probe raises so the @@ -104,24 +118,32 @@ def _compile_search_regex(pattern: str) -> re.Pattern[str]: async def _run_search_with_timeout( - fn: Callable[[], list[FileSearchResult]], + work: Awaitable[list[FileSearchResult]], ) -> list[FileSearchResult]: - """Run ``fn`` in a worker thread with a bounded wall-clock timeout. + """Await ``work`` under a bounded wall-clock timeout. + + The one bound covers both shapes of search: a whole scan offloaded with + :func:`asyncio.to_thread` (what the stores in this package do) and the base + :meth:`AgentFileStore.search` pipeline, which keeps store I/O on the event + loop and offloads only the per-file regex work. In both cases the + model-supplied pattern executes in a worker thread, never on the loop. Raises: ValueError: When the search does not complete within :data:`_SEARCH_TIMEOUT_SECONDS` seconds. """ try: - return await asyncio.wait_for(asyncio.to_thread(fn), timeout=_SEARCH_TIMEOUT_SECONDS) + return await asyncio.wait_for(work, timeout=_SEARCH_TIMEOUT_SECONDS) except asyncio.TimeoutError as exc: # On Python 3.10 ``asyncio.wait_for`` raises ``asyncio.TimeoutError`` # which is distinct from the builtin ``TimeoutError`` (the two were # unified in 3.11). Catching the asyncio alias works on every # supported version. raise ValueError( - f"Regex search did not complete within {_SEARCH_TIMEOUT_SECONDS:g} seconds. " - "Use a more specific pattern (avoid nested quantifiers such as '(a+)+')." + f"Search did not complete within {_SEARCH_TIMEOUT_SECONDS:g} seconds. The bound covers " + "the whole search, so this is either a pathological pattern (avoid nested quantifiers " + "such as '(a+)+') or a store too slow to read this many files in time. Narrow the " + "pattern, or search a smaller directory." ) from exc @@ -209,6 +231,19 @@ def _matches_glob(file_name: str, glob_pattern: str | None) -> bool: return fnmatch.fnmatchcase(file_name.lower(), glob_pattern.lower()) +def _combine_search_path(directory: str, relative: str) -> str: + """Join a search ``directory`` and a path relative to it into one store path. + + Both sides may be blank (the root, or the directory itself), so the result is + trimmed rather than assembled with a bare ``/``. + """ + base = directory.strip("/") + tail = relative.strip("/") + if not base: + return tail + return f"{base}/{tail}" if tail else base + + def _apply_replace(content: str, old_string: str, new_string: str, replace_all: bool) -> tuple[str, int]: """Replace ``old_string`` with ``new_string`` in ``content``. @@ -232,12 +267,17 @@ def _apply_replace(content: str, old_string: str, new_string: str, replace_all: def _split_lines_keepends(content: str) -> list[str]: r"""Split ``content`` into lines on ``\n`` only, keeping the terminator attached. - Splits solely on ``\n`` (a trailing ``\r`` stays as line content), reproducing - :func:`_search_file_content`'s ``content.split("\n")`` enumeration exactly, so a - ``line_number`` obtained from ``grep`` always targets the same line here and stays - in range. This means the result has ``len(content.split("\n"))`` elements: a - trailing ``\n`` yields a final empty (editable) line, and empty content yields a - single empty line. ``"".join(...)`` reproduces ``content`` verbatim. + This is the single definition of a line used by ``read_lines``, ``replace_lines`` and the + :class:`AgentFileStore` implementations in this package, so for those stores a ``line_number`` + obtained from ``grep`` always targets the same line in the others and stays in range. A custom + store supplies its own :meth:`AgentFileStore.search`, which must number by this split but does + not inherit it; nothing checks that at runtime, so a store numbering differently edits the + wrong line silently. + + Splitting solely on ``\n`` (a trailing ``\r`` + stays attached to the line) means the result has ``len(content.split("\n"))`` + elements: a trailing ``\n`` yields a final empty (editable) line, and empty content + yields a single empty line. ``"".join(...)`` reproduces ``content`` verbatim. """ segments = content.split("\n") lines = [segment + "\n" for segment in segments[:-1]] @@ -245,7 +285,17 @@ def _split_lines_keepends(content: str) -> list[str]: return lines -def _apply_replace_lines(content: str, edits: list[tuple[int, str]]) -> str: +def _strip_line_terminator(line: str) -> str: + r"""Return ``line`` without its trailing ``\r\n`` or ``\n``. + + A lone ``\r`` is content under the ``\n``-only split :func:`_split_lines_keepends` + publishes, so it stays: removing it would let ``expected_line`` authorize an edit to a + line it does not actually equal, and would hide the character from a pattern matching it. + """ + return line[:-2] if line.endswith("\r\n") else line.removesuffix("\n") + + +def _apply_replace_lines(content: str, edits: list[tuple[int, str, str | None]]) -> str: r"""Apply literal 1-based line replacements to ``content``. Each ``new_line`` is written **verbatim** in place of the target line, @@ -254,36 +304,96 @@ def _apply_replace_lines(content: str, edits: list[tuple[int, str]]) -> str: and its terminator), and a ``new_line`` containing embedded newlines expands one line into several. + When an edit carries an ``expected_line``, the current text of the target + line must match it once both sides have their terminator stripped. That + turns a stale or mis-numbered edit into a refusal instead of a silent + overwrite of the wrong line. + + The comparison is against the snapshot this call was given, and the write + that follows is unconditional, so it does not make the edit atomic. A writer + that changes the file after the provider's read still has its change + overwritten: the provider's lock serializes its own tools within one process, + not other processes or other providers sharing the store. Closing that would + need a conditional or versioned write on :class:`AgentFileStore` itself. + Raises :class:`ValueError` when no edits are provided, when any line number - is out of range, or when a line number is targeted more than once. + is out of range, when a line number is targeted more than once, or when an + ``expected_line`` does not match. """ if not edits: raise ValueError("At least one line edit must be provided.") lines = _split_lines_keepends(content) seen: set[int] = set() - for line_number, _ in edits: + for line_number, _, expected_line in edits: if line_number in seen: raise ValueError(f"Duplicate line_number {line_number} in edits.") seen.add(line_number) if line_number < 1 or line_number > len(lines): raise ValueError(f"line_number {line_number} is out of range (file has {len(lines)} lines).") - for line_number, new_line in edits: + if expected_line is not None: + actual = _strip_line_terminator(lines[line_number - 1]) + if actual != _strip_line_terminator(expected_line): + raise ValueError( + f"line_number {line_number} does not match the expected text. " + "Re-read the file to get current line numbers." + ) + for line_number, new_line, _ in edits: lines[line_number - 1] = new_line return "".join(lines) -def _line_edits(edits: list[Any]) -> list[tuple[int, str]]: - """Normalize ``replace_lines`` edits (pydantic models or dicts) to ``(line_number, new_line)``.""" - normalized: list[tuple[int, str]] = [] +def _line_edits(edits: list[Any]) -> list[tuple[int, str, str | None]]: + """Normalize ``replace_lines`` edits (pydantic models or dicts) to tuples. + + Each tuple is ``(line_number, new_line, expected_line)``, where + ``expected_line`` is ``None`` when the caller did not supply one. + """ + normalized: list[tuple[int, str, str | None]] = [] for edit in edits: if isinstance(edit, Mapping): mapping = cast("Mapping[str, Any]", edit) - normalized.append((int(mapping["line_number"]), str(mapping["new_line"]))) + expected = mapping.get("expected_line") + normalized.append(( + int(mapping["line_number"]), + str(mapping["new_line"]), + None if expected is None else str(expected), + )) else: - normalized.append((int(edit.line_number), str(edit.new_line))) + expected = getattr(edit, "expected_line", None) + normalized.append(( + int(edit.line_number), + str(edit.new_line), + None if expected is None else str(expected), + )) return normalized +def _slice_lines(content: str, start_line: int, end_line: int | None) -> list[str]: + """Return the 1-based inclusive ``[start_line, end_line]`` slice of ``content``, terminators kept. + + Uses :func:`_split_lines_keepends`, so a ``line_number`` from ``grep`` on one of this + package's stores addresses the same line here and in ``replace_lines``, including the + trailing empty line of a newline-terminated file. ``end_line`` of ``None`` reads to the end of the file, and an + ``end_line`` past the last line is clamped rather than rejected. Validating + ``start_line`` before clamping keeps every successful slice non-empty. + + Raises: + ValueError: When ``start_line`` or ``end_line`` is not positive, when ``end_line`` + precedes ``start_line``, or when ``start_line`` is past the last line. + """ + lines = _split_lines_keepends(content) + total = len(lines) + if start_line < 1: + raise ValueError(f"start_line must be a positive integer, got {start_line}.") + if end_line is not None and end_line < 1: + raise ValueError(f"end_line must be a positive integer, got {end_line}.") + if end_line is not None and end_line < start_line: + raise ValueError(f"end_line ({end_line}) must not be less than start_line ({start_line}).") + if start_line > total: + raise ValueError(f"start_line {start_line} is out of range (file has {total} lines).") + return lines[start_line - 1 : total if end_line is None else min(end_line, total)] + + @experimental(feature_id=ExperimentalFeature.HARNESS) class FileSearchMatch(SerializationMixin): """Represent one line within a file that matched a search pattern.""" @@ -296,7 +406,9 @@ def __init__(self, line_number: int, line: str) -> None: Args: line_number: The 1-based line number where the match was found. - line: The content of the matching line (trailing ``\r`` removed). + line: The matching line. Implementers should report it verbatim, terminator + included, so it can be reused as a replacement line; unlike + ``line_number``, that is a recommendation rather than a requirement. """ if line_number < 1: raise ValueError("line_number must be a positive integer.") @@ -478,27 +590,35 @@ def __repr__(self) -> str: def _search_file_content(file_name: str, content: str, regex: re.Pattern[str]) -> FileSearchResult | None: r"""Search one file's content and return a :class:`FileSearchResult` if any lines match. - Lines are split on ``\n`` (so ``\r`` at the end of each line is stripped on - the matching line itself). A snippet of up to ``±_SEARCH_SNIPPET_RADIUS`` - characters around the first match is included. Returns ``None`` when no - lines match. + Lines are split by :func:`_split_lines_keepends` and reported verbatim, terminator + included, so a match can be fed straight back to ``replace_lines`` as a ``new_line`` + without losing a ``\r\n``. The pattern is matched against the line without its + trailing terminator, so ``$`` anchors to the end of the line's text even on a CRLF + file, and conversely a pattern matching a literal ``\r`` does not match the one a + CRLF line ends with. A snippet of up to ``±_SEARCH_SNIPPET_RADIUS`` characters around + the first match is included. Returns ``None`` when no lines match. """ - lines = content.split("\n") + lines = _split_lines_keepends(content) matching_lines: list[FileSearchMatch] = [] first_snippet: str | None = None line_start_offset = 0 for line_number, line in enumerate(lines, start=1): - match = regex.search(line) + # Inlined rather than calling _strip_line_terminator: this runs once per line of + # every searched file, and the extra call is measurable on a large corpus. + # Same rule as _strip_line_terminator: a lone \r is content, so only a whole + # \r\n comes off. + scanned = line[:-2] if line.endswith("\r\n") else line.removesuffix("\n") + match = regex.search(scanned) if match is not None: - matching_lines.append(FileSearchMatch(line_number=line_number, line=line.rstrip("\r"))) + matching_lines.append(FileSearchMatch(line_number=line_number, line=line)) if first_snippet is None: char_index = line_start_offset + match.start() snippet_start = max(0, char_index - _SEARCH_SNIPPET_RADIUS) snippet_end = min(len(content), char_index + (match.end() - match.start()) + _SEARCH_SNIPPET_RADIUS) first_snippet = content[snippet_start:snippet_end] - # Advance past this line and the implied '\n' separator. - line_start_offset += len(line) + 1 + # Advance past this line; its terminator is already part of its length. + line_start_offset += len(line) if not matching_lines: return None @@ -582,7 +702,104 @@ async def file_exists(self, path: str) -> bool: path: The relative path of the file to check. """ - @abstractmethod + @staticmethod + def split_lines(content: str) -> list[str]: + r"""Split ``content`` into the lines this store's line numbers address. + + This is the published definition of a line for the whole file-access + surface: ``read_lines``, ``replace_lines`` and every ``line_number`` + reported by :meth:`search` are coordinates in this list. Splitting is on + ``\n`` only, with the terminator kept attached, so a trailing ``\r`` + stays on its line, a trailing ``\n`` yields a final empty (editable) + line, and ``"".join(...)`` reproduces ``content`` verbatim. + + A store that overrides :meth:`search` must number its matches by this + split — otherwise ``grep`` and the line editor disagree and an edit + lands on the wrong line. The rule is per-SDK: it is not required to + match the .NET implementation, only to be consistent within this one. + """ + return _split_lines_keepends(content) + + @staticmethod + def scan_content(file_name: str, content: str, regex: re.Pattern[str]) -> FileSearchResult | None: + """Find every line of ``content`` matching ``regex``, numbered by :meth:`split_lines`. + + This is the numbering primitive the base :meth:`search` uses, published so a + store that supplies its own :meth:`search` can produce aligned results + rather than re-deriving them. Lines are reported verbatim, terminator + included; the pattern is matched against the line without its terminator. + + Args: + file_name: The name recorded on the result, relative to the searched directory. + content: The file's full text. + regex: A compiled pattern, normally from the same source string passed + to :meth:`search`. + + Returns: + The match metadata, or ``None`` when no line matches. + """ + return _search_file_content(file_name, content, regex) + + async def find_matching_files( + self, + directory: str, + regex_pattern: str, + glob_pattern: str | None = None, + *, + recursive: bool = False, + ) -> list[str]: + """Return the names of the files that **may** satisfy this search. + + A candidate is a file whose content **may** contain text matching ``regex_pattern`` and + whose name **may** match ``glob_pattern``. This is the hook a store uses to narrow the + search to the files worth reading. Semantics are deliberately a **superset**: + returning a file that turns out not to match is harmless — the base + :meth:`search` re-scans every candidate and discards it — while omitting + a file loses the match. A backend with a native search index should + override this and push ``regex_pattern`` down to it, widening rather than + guessing where the dialect cannot express the pattern. + + The default implementation has no index to narrow with, so it walks :meth:`list_children` + and returns every file in scope, leaving :meth:`search` to read and scan all of them. + Override this when the backing store can answer either question more cheaply than that — + a name index for ``glob_pattern``, a content or full-text index for ``regex_pattern`` — and + return the candidates it finds. That is the whole purpose of the hook: the store does the + narrowing it is good at, and the base keeps the scanning and the line numbering. Overriding + :meth:`search` instead is also supported, but then line numbering is the store's + responsibility (see :meth:`split_lines`), and nothing checks it at runtime. + + Args: + directory: The relative directory to search. Use ``""`` for the root. + regex_pattern: The pattern :meth:`search` was called with, as a hint. It is the bare + source string: :meth:`search` compiles it **case-insensitively**, so an index + queried case-sensitively with it will miss files and silently lose those matches. + glob_pattern: The same optional glob :meth:`search` received, matched + against each file's path relative to ``directory``, also + **case-insensitively**. The same rule applies: widen rather than narrow when + the backend cannot reproduce that, since returning only case-exact names + drops files the caller would have got. + + Keyword Args: + recursive: When ``False`` (default) only direct children are in scope. + + Returns: + File paths relative to ``directory``, using forward slashes. + """ + del regex_pattern # No index to narrow with here; a native backend overrides this. + names: list[str] = [] + pending: list[str] = [""] + while pending: + relative_dir = pending.pop() + listed = await self.list_children(_combine_search_path(directory, relative_dir)) + for entry in listed: + child = f"{relative_dir}/{entry.name}".lstrip("/") + if entry.type == FileStoreEntry.DIRECTORY: + if recursive: + pending.append(child) + elif _matches_glob(child, glob_pattern): + names.append(child) + return names + async def search( self, directory: str, @@ -593,6 +810,24 @@ async def search( ) -> list[FileSearchResult]: """Search files in ``directory`` for content matching ``regex_pattern``. + The base implementation asks :meth:`find_matching_files` which files to + consider, then reads and scans each one itself, so every ``line_number`` + it reports is a coordinate in :meth:`split_lines` by construction and a + number from ``grep`` always addresses the same line in ``read_lines`` and + ``replace_lines``. Store I/O stays on the event loop; only the + model-supplied regex runs in a worker thread. + + Overriding this method is supported — a backend that can perform the + whole search natively should — but the override then owns line numbering. + An override must report ``line_number`` as a 1-based coordinate into + :meth:`split_lines` of the same content :meth:`read` returns, and should + report the matching line verbatim, terminator included; + :meth:`scan_content` produces both correctly. Numbering against anything + else is a bug with a silent failure mode: the search looks correct, and + the damage appears later when a line edit applies to a line the caller + never saw. Cover it with a test that greps and then edits by the reported + number. + Args: directory: The relative directory to search. Use ``""`` for the root. regex_pattern: A regular expression matched against file contents @@ -612,7 +847,72 @@ async def search( The list of files whose content matched, with snippet and matching line metadata. Each result's ``file_name`` is the path relative to ``directory`` (forward slashes). + + Raises: + ValueError: When the search does not complete within + :data:`_SEARCH_TIMEOUT_SECONDS` seconds. + """ + regex = _compile_search_regex(regex_pattern) + return await _run_search_with_timeout(self._scan_candidate_files(directory, regex, glob_pattern, recursive)) + + async def _scan_candidate_files( + self, + directory: str, + regex: re.Pattern[str], + glob_pattern: str | None, + recursive: bool, + ) -> list[FileSearchResult]: + """Read and scan each candidate from :meth:`find_matching_files`. + + The glob and the non-recursive rule are re-applied here so a store that + over-returns (which :meth:`find_matching_files` explicitly permits) + cannot widen the caller's scope. """ + names = await self.find_matching_files(directory, regex.pattern, glob_pattern, recursive=recursive) + results: list[FileSearchResult] = [] + batch: list[tuple[str, str]] = [] + batch_chars = 0 + + def scan_batch(pending: list[tuple[str, str]]) -> list[FileSearchResult]: + found: list[FileSearchResult] = [] + for candidate_name, candidate_content in pending: + # Called on the class, not the instance: a store that overrides the public + # scan_content must not be able to skew the numbers while still counting as + # aligned by construction. + result = AgentFileStore.scan_content(candidate_name, candidate_content, regex) + if result is not None: + found.append(result) + return found + + for name in names: + if not _matches_glob(name, glob_pattern) or (not recursive and "/" in name): + continue + try: + content = await self.read(_combine_search_path(directory, name)) + except (OSError, ValueError): + # ``read`` raises ValueError for non-UTF-8 bytes and for symlinked paths, and + # OSError for a file deleted or made unreadable under us. Skip either so one + # unreadable file cannot abort the whole search, mirroring + # FileSystemAgentFileStore's own behaviour. + logger.warning("Skipping unreadable file during search: %s", name) + continue + if content is None: + continue # Deleted between enumeration and read. + batch.append((name, content)) + batch_chars += len(content) + # Scan in batches rather than one file at a time: the offload exists to keep + # a pathological regex off the event loop, and one hop per file costs more + # than the scan itself once per-file work is sub-millisecond. Bounding the + # batch by size rather than count keeps peak memory independent of how many + # candidates the store returned, and the count cap covers the case where the + # content itself is empty. + if batch_chars >= _SCAN_BATCH_CHARS or len(batch) >= _SCAN_BATCH_FILES: + results.extend(await asyncio.to_thread(scan_batch, batch)) + batch = [] + batch_chars = 0 + if batch: + results.extend(await asyncio.to_thread(scan_batch, batch)) + return results @abstractmethod async def create_directory(self, path: str) -> None: @@ -754,12 +1054,12 @@ def scan() -> list[FileSearchResult]: relative_display = display[len(prefix) :] if not _matches_glob(relative_display, glob_pattern): continue - result = _search_file_content(relative_display, file_content, regex) + result = AgentFileStore.scan_content(relative_display, file_content, regex) if result is not None: results.append(result) return results - return await _run_search_with_timeout(scan) + return await _run_search_with_timeout(asyncio.to_thread(scan)) async def create_directory(self, path: str) -> None: """No-op: directories are implicit from file paths in the in-memory store.""" @@ -848,9 +1148,22 @@ def _resolve_safe_directory_path(self, relative_directory: str) -> Path: Empty and whitespace-only inputs both resolve to the root directory, matching the behavior of ``_normalize_relative_path(..., is_directory=True)`` and the convention used by :class:`InMemoryAgentFileStore`. + + The root is screened here rather than trusted. Every other directory reaches + :meth:`_resolve_safe_path`, which rejects a link on any segment; the root itself + never passed through that, so a planted or swapped root was walked as-is. The + root is created lazily, so it can be a link before first use as easily as after. """ normalized = _normalize_relative_path(relative_directory, is_directory=True) if not normalized: + # ``lexists`` rather than ``exists``: the root is created lazily, so a missing one is + # normal and must stay cheap, but a dangling link still has to be rejected rather than + # read as absent. + if os.path.lexists(self._root_path): + if is_link_or_reparse_point(self._root_path): + raise ValueError("Invalid path: the resolved path contains a symbolic link or reparse point.") + if self._root_path.resolve() != self._root_path: + raise ValueError("Invalid path: the resolved path escapes the root directory.") return self._root_path return self._resolve_safe_path(normalized) @@ -1030,7 +1343,9 @@ async def search( """ full_dir = self._resolve_safe_directory_path(directory) regex = _compile_search_regex(regex_pattern) - return await _run_search_with_timeout(lambda: self._search_files_sync(full_dir, regex, glob_pattern, recursive)) + return await _run_search_with_timeout( + asyncio.to_thread(self._search_files_sync, full_dir, regex, glob_pattern, recursive) + ) @staticmethod def _enumerate_search_files(full_dir: Path, recursive: bool) -> list[tuple[str, Path]]: @@ -1074,20 +1389,33 @@ def _search_files_sync( if not _matches_glob(relative_name, glob_pattern): continue try: - file_content = entry.read_text(encoding="utf-8") - except UnicodeDecodeError: - # Skip binary or otherwise non-UTF-8 files so a single - # un-decodable entry doesn't abort the whole directory search. - # Log per file so operators can audit which files were skipped. - logger.warning("Skipping non-UTF-8 file during search: %s", entry) + # Re-checked here, not only during enumeration: a candidate can be swapped for a + # link in between. O_NOFOLLOW makes the read itself atomic where the platform has + # it, and this narrows the window on Windows, where it does not exist. + if is_link_or_reparse_point(entry): + logger.warning("Skipping symlinked file during search: %s", entry) + skipped.append(relative_name) + continue + # Read through the same no-follow reader the store's own read() uses; reopening + # by path would follow a link and return content from outside the root. + content_or_none = FileSystemAgentFileStore._read_file_sync(entry) + if content_or_none is None: + continue + file_content = content_or_none + except (OSError, ValueError): + # _read_file_sync raises ValueError for non-UTF-8 content and for a path that + # became a symlink since enumeration, and OSError if it vanished. Skip the file + # either way, so one bad entry cannot abort the whole directory search, and log + # per file so operators can audit what was left out. + logger.warning("Skipping unreadable file during search: %s", entry) skipped.append(relative_name) continue - result = _search_file_content(relative_name, file_content, regex) + result = AgentFileStore.scan_content(relative_name, file_content, regex) if result is not None: results.append(result) if skipped: logger.info( - "Search under %s skipped %d non-UTF-8 file(s) (matched %d).", + "Search under %s skipped %d unreadable file(s) (matched %d).", full_dir, len(skipped), len(results), @@ -1117,6 +1445,23 @@ class _ReadFileInput(BaseModel): file_name: Annotated[str, Field(description="Name (relative path) of the file to read.")] +class _ReadLinesInput(BaseModel): + """Input schema for ``file_access_read_lines``.""" + + file_name: Annotated[str, Field(description="Name (relative path) of the file to read.")] + start_line: Annotated[int, Field(description="1-based line number to read from, inclusive.")] + end_line: Annotated[ + int | None, + Field( + default=None, + description=( + "1-based line number to read to, inclusive. Omit to read to the end of the file; " + "a value past the last line is clamped to it." + ), + ), + ] = None + + class _DeleteFileInput(BaseModel): """Input schema for ``file_access_delete``.""" @@ -1168,6 +1513,20 @@ class _LineEdit(BaseModel): ) ), ] + expected_line: Annotated[ + str | None, + Field( + default=None, + description=( + "Optional: the text you believe is currently on that line, as reported by " + "file_access_grep. Give the line's own text only: file_access_read_lines prefixes " + "each line with its number and a tab, and that prefix is not part of the line. " + "When supplied, the edit is rejected unless it matches, which catches an " + "out-of-date line number or a file that changed since you looked. The trailing " + "newline is ignored in the comparison." + ), + ), + ] = None class _ReplaceLinesInput(BaseModel): @@ -1209,6 +1568,8 @@ class FileAccessProvider(ContextProvider): - ``file_access_write`` — Write a file (refuses to overwrite by default). - ``file_access_read`` — Read the content of a file by name. + - ``file_access_read_lines`` — Read a range of lines from a file by 1-based + inclusive line number. - ``file_access_delete`` — Delete a file by name. - ``file_access_ls`` — List the direct child files and subdirectories of a directory, optionally filtered by a glob pattern. @@ -1218,7 +1579,7 @@ class FileAccessProvider(ContextProvider): - ``file_access_replace_lines`` — Replace whole lines within a file. When ``disable_write_tools`` is set, only the read-only tools (``file_access_read``, - ``file_access_ls``, ``file_access_grep``) are advertised. + ``file_access_read_lines``, ``file_access_ls``, ``file_access_grep``) are advertised. Unlike :class:`~agent_framework.MemoryContextProvider`, which provides session-scoped memory that may be isolated per session, @@ -1239,7 +1600,7 @@ class FileAccessProvider(ContextProvider): to drive that handshake; otherwise these tools never run. To run unattended you can disable approval at the source with - ``disable_readonly_tool_approval`` (read, ls, grep) and/or + ``disable_readonly_tool_approval`` (read, read_lines, ls, grep) and/or ``disable_write_tool_approval`` (write, delete, replace, replace_lines), which register the affected tools with ``approval_mode="never_require"``. Alternatively, keep approval on and supply one of the static auto-approval @@ -1247,7 +1608,7 @@ class FileAccessProvider(ContextProvider): ``auto_approval_rules``: - :meth:`read_only_tools_auto_approval_rule` — auto-approves only the - read-only tools (read, ls, grep), while still prompting for the tools that + read-only tools (read, read_lines, ls, grep), while still prompting for the tools that modify the store (write, delete, replace, replace_lines). - :meth:`all_tools_auto_approval_rule` — auto-approves every file-access tool, including the write tools. @@ -1264,6 +1625,8 @@ class FileAccessProvider(ContextProvider): WRITE_TOOL_NAME = "file_access_write" #: Name of the tool that reads a file. READ_TOOL_NAME = "file_access_read" + #: Name of the tool that reads a range of lines from a file. + READ_LINES_TOOL_NAME = "file_access_read_lines" #: Name of the tool that deletes a file. DELETE_TOOL_NAME = "file_access_delete" #: Name of the tool that lists the files and subdirectories of a directory. @@ -1278,6 +1641,7 @@ class FileAccessProvider(ContextProvider): #: Names of the tools that only read from (never modify) the file store. _READ_ONLY_TOOL_NAMES: frozenset[str] = frozenset({ READ_TOOL_NAME, + READ_LINES_TOOL_NAME, LS_TOOL_NAME, GREP_TOOL_NAME, }) @@ -1315,12 +1679,12 @@ def __init__( instructions: Optional instruction override. When ``None`` the default file-access instructions are used. disable_write_tools: When ``True``, only the read-only tools - (``file_access_read``, ``file_access_ls``, ``file_access_grep``) + (``file_access_read``, ``file_access_read_lines``, ``file_access_ls``, ``file_access_grep``) are advertised; the write tools (``file_access_write``, ``file_access_delete``, ``file_access_replace``, ``file_access_replace_lines``) are hidden from the model. disable_readonly_tool_approval: When ``True``, the read-only tools - (``file_access_read``, ``file_access_ls``, ``file_access_grep``) + (``file_access_read``, ``file_access_read_lines``, ``file_access_ls``, ``file_access_grep``) are registered with ``approval_mode="never_require"`` so they run without host approval. Defaults to ``False`` (approval required). disable_write_tool_approval: When ``True``, the write tools @@ -1363,10 +1727,10 @@ def read_only_tools_auto_approval_rule(function_call: Content) -> bool: The tools exposed by :class:`FileAccessProvider` always require approval. Pass this rule to :class:`~agent_framework.ToolApprovalMiddleware` (via ``auto_approval_rules``) to automatically approve the tools that read - from the store (``file_access_read``, ``file_access_ls``, and - ``file_access_grep``), while still prompting for the tools that modify it - (``file_access_write``, ``file_access_delete``, ``file_access_replace``, - and ``file_access_replace_lines``). + from the store (``file_access_read``, ``file_access_read_lines``, + ``file_access_ls``, and ``file_access_grep``), while still prompting for + the tools that modify it (``file_access_write``, ``file_access_delete``, + ``file_access_replace``, and ``file_access_replace_lines``). Hosted-tool calls (those carrying a ``server_label``) are never auto-approved, even when their name matches a file-access tool, so the @@ -1375,11 +1739,12 @@ def read_only_tools_auto_approval_rule(function_call: Content) -> bool: .. warning:: **Security — avoid tool-name collisions.** This rule approves local tool calls by tool name only (``file_access_read``, - ``file_access_ls``, and ``file_access_grep``). Any other local tool - registered under one of these names — for example a tool with a - caller-configurable name such as the shell tool — may also be - auto-approved, bypassing the human approval boundary. Ensure no other - tool collides with these reserved names. + ``file_access_read_lines``, ``file_access_ls``, and + ``file_access_grep``). Any other local tool registered under one of + these names — for example a tool with a caller-configurable name such + as the shell tool — may also be auto-approved, bypassing the human + approval boundary. Ensure no other tool collides with these reserved + names. Args: function_call: The pending ``function_call`` content. @@ -1411,13 +1776,13 @@ def all_tools_auto_approval_rule(function_call: Content) -> bool: .. warning:: **Security — avoid tool-name collisions.** This rule approves local tool calls by tool name only (``file_access_write``, - ``file_access_read``, ``file_access_delete``, ``file_access_ls``, - ``file_access_grep``, ``file_access_replace``, and - ``file_access_replace_lines``). Any other local tool registered under - one of these names — for example a tool with a caller-configurable - name such as the shell tool — may also be auto-approved, bypassing - the human approval boundary. Ensure no other tool collides with these - reserved names. + ``file_access_read``, ``file_access_read_lines``, + ``file_access_delete``, ``file_access_ls``, ``file_access_grep``, + ``file_access_replace``, and ``file_access_replace_lines``). Any + other local tool registered under one of these names — for example a + tool with a caller-configurable name such as the shell tool — may + also be auto-approved, bypassing the human approval boundary. Ensure + no other tool collides with these reserved names. Args: function_call: The pending ``function_call`` content. @@ -1461,7 +1826,7 @@ async def file_access_write(file_name: str, content: str, overwrite: bool = Fals @tool(name=FileAccessProvider.READ_TOOL_NAME, schema=_ReadFileInput, approval_mode=readonly_approval) async def file_access_read(file_name: str) -> str: - """Read the content of a file by name. Returns the file content or a message indicating the file could not be read.""" # ruff:ignore[line-too-long] + r"""Read the content of a file by name. Returns the file content or a message indicating the file could not be read. Line numbers count lines split on \n only: a lone \r never starts a new line, each line keeps its own terminator, and content ending in a newline has a final empty line.""" # ruff:ignore[line-too-long] try: normalized = _normalize_relative_path(file_name) content = await self.store.read(normalized) @@ -1471,6 +1836,26 @@ async def file_access_read(file_name: str) -> str: return f"Could not read file '{file_name}': {exc.strerror or exc}" return content if content is not None else f"File '{file_name}' not found." + @tool( + name=FileAccessProvider.READ_LINES_TOOL_NAME, + schema=_ReadLinesInput, + approval_mode=readonly_approval, + ) + async def file_access_read_lines(file_name: str, start_line: int, end_line: int | None = None) -> str: + r"""Read part of a file by 1-based inclusive line number; omit end_line to read to the end of the file, and an end_line past the last line is clamped. Each line is prefixed with its number and a tab; everything after that tab is verbatim, including the line's own terminator, so it can be reused as a file_access_replace_lines new_line. Line numbers count lines split on \n only: a lone \r never starts a new line, each line keeps its own terminator, and content ending in a newline has a final empty line.""" # ruff:ignore[line-too-long] + try: + normalized = _normalize_relative_path(file_name) + content = await self.store.read(normalized) + if content is None: + return f"File '{file_name}' not found." + sliced = _slice_lines(content, start_line, end_line) + except ValueError as exc: + return f"Could not read lines from file '{file_name}': {exc}" + except OSError as exc: + return f"Could not read lines from file '{file_name}': {exc.strerror or exc}" + # Each line keeps its terminator, so it doubles as the row separator. + return "".join(f"{number}\t{line}" for number, line in enumerate(sliced, start_line)) + @tool(name=FileAccessProvider.DELETE_TOOL_NAME, schema=_DeleteFileInput, approval_mode=write_approval) async def file_access_delete(file_name: str) -> str: """Delete a file by name.""" @@ -1529,7 +1914,7 @@ async def file_access_replace( approval_mode=write_approval, ) async def file_access_replace_lines(file_name: str, edits: list[_LineEdit]) -> str: - """Replace lines in a file. Provide a list of edits, each with a 1-based line_number and a literal new_line (include your own trailing newline); an empty new_line deletes the line, including its line break. Fails on out-of-range or duplicate line numbers.""" # ruff:ignore[line-too-long] + r"""Replace lines in a file. Provide a list of edits, each with a 1-based line_number and a literal new_line (include your own trailing newline); an empty new_line deletes the line, including its line break. Fails on out-of-range or duplicate line numbers. Line numbers count lines split on \n only: a lone \r never starts a new line, each line keeps its own terminator, and content ending in a newline has a final empty line.""" # ruff:ignore[line-too-long] try: normalized = _normalize_relative_path(file_name) async with self._write_lock: @@ -1550,7 +1935,7 @@ async def file_access_grep( glob_pattern: str | None = None, directory: str | None = None, ) -> list[dict[str, Any]] | str: - """Search the contents of files in the store using a case-insensitive regular expression. + r"""Search the contents of files in the store using a case-insensitive regular expression. The search runs recursively across all subdirectories. Optionally restrict the search to a ``directory`` (relative path), and filter which files to search using a glob ``glob_pattern`` @@ -1561,6 +1946,11 @@ async def file_access_grep( Leave empty or omit to search all files. Returns matching results whose file_name values are paths relative to the store root (directly usable with file_access_read), along with snippets and matching lines with line numbers. + Stores are expected to report each matching line verbatim, including its own line + terminator, so it can normally be reused as a file_access_replace_lines new_line; a + custom store may not, so prefer file_access_read_lines when the exact text matters. + Line numbers count lines split on \n only, with a final empty line when content ends in a + newline. The regex_pattern must be 256 characters or fewer. """ glob_filter = glob_pattern if glob_pattern and glob_pattern.strip() else None @@ -1583,7 +1973,7 @@ async def file_access_grep( return output context.extend_instructions(self.source_id, [self.instructions]) - tools = [file_access_read, file_access_ls, file_access_grep] + tools = [file_access_read, file_access_read_lines, file_access_ls, file_access_grep] if not self.disable_write_tools: tools.extend([file_access_write, file_access_delete, file_access_replace, file_access_replace_lines]) context.extend_tools(self.source_id, tools) diff --git a/python/packages/core/agent_framework/_harness/_file_memory.py b/python/packages/core/agent_framework/_harness/_file_memory.py index cd61cdb6873..1dff633906c 100644 --- a/python/packages/core/agent_framework/_harness/_file_memory.py +++ b/python/packages/core/agent_framework/_harness/_file_memory.py @@ -73,6 +73,7 @@ "compacted or truncated. This ensures important data remains accessible across long-running sessions." ) + _DESCRIPTION_SUFFIX = "_description.md" _MEMORY_INDEX_FILE_NAME = "memories.md" _MAX_INDEX_ENTRIES = 50 @@ -189,6 +190,18 @@ class _LineEdit(BaseModel): ) ), ] + expected_line: Annotated[ + str | None, + Field( + default=None, + description=( + "Optional: the text you believe is currently on that line, as reported by " + "file_memory_grep. When supplied, the edit is rejected unless it matches, which " + "catches an out-of-date line number or a file that changed since you looked. " + "The trailing newline is ignored in the comparison." + ), + ), + ] = None class _ReplaceLinesInput(BaseModel): @@ -350,7 +363,7 @@ async def file_memory_write(file_name: str, content: str, description: str | Non @tool(name="file_memory_read", schema=_ReadFileInput, approval_mode="never_require") async def file_memory_read(file_name: str) -> str: - """Read the content of a memory file by name. Returns the file content or a message indicating the file was not found.""" # ruff:ignore[line-too-long] + r"""Read the content of a memory file by name. Returns the file content or a message indicating the file was not found. Line numbers count lines split on \n only: a lone \r never starts a new line, each line keeps its own terminator, and content ending in a newline has a final empty line.""" # ruff:ignore[line-too-long] try: normalized = _normalize_relative_path(file_name) except ValueError as exc: @@ -443,7 +456,7 @@ async def file_memory_replace( @tool(name="file_memory_replace_lines", schema=_ReplaceLinesInput, approval_mode="never_require") async def file_memory_replace_lines(file_name: str, edits: list[_LineEdit]) -> str: - """Replace lines in a memory file. Provide a list of edits, each with a 1-based line_number and a literal new_line (include your own trailing newline); an empty new_line deletes the line, including its line break. Fails on out-of-range or duplicate line numbers.""" # ruff:ignore[line-too-long] + r"""Replace lines in a memory file. Provide a list of edits, each with a 1-based line_number and a literal new_line (include your own trailing newline); an empty new_line deletes the line, including its line break. Fails on out-of-range or duplicate line numbers. Line numbers count lines split on \n only: a lone \r never starts a new line, each line keeps its own terminator, and content ending in a newline has a final empty line.""" # ruff:ignore[line-too-long] try: normalized = _normalize_relative_path(file_name) except ValueError as exc: @@ -478,11 +491,15 @@ async def file_memory_grep( glob_filter = glob_pattern if glob_pattern and glob_pattern.strip() else None try: results = await self.store.search(working_folder, regex_pattern, glob_filter, recursive=False) + # The index and the description sidecars are the provider's own bookkeeping and + # are never shown to the agent, so a match inside one is dropped rather than + # reported. + visible = [result for result in results if not _is_internal_file(result.file_name)] except ValueError as exc: return f"Could not search memory files: {exc}" except OSError as exc: return f"Could not search memory files: {exc.strerror or exc}" - return [result.to_dict() for result in results if not _is_internal_file(result.file_name)] + return [result.to_dict() for result in visible] context.extend_instructions(self.source_id, [self.instructions]) context.extend_tools( diff --git a/python/packages/core/tests/core/test_harness_file_access.py b/python/packages/core/tests/core/test_harness_file_access.py index 19497be97d3..2789f438228 100644 --- a/python/packages/core/tests/core/test_harness_file_access.py +++ b/python/packages/core/tests/core/test_harness_file_access.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import json import os import re @@ -31,11 +32,14 @@ from agent_framework._filesystem import is_link_or_reparse_point from agent_framework._harness import _file_access as _file_access_module from agent_framework._harness._file_access import ( + _SEARCH_SNIPPET_RADIUS, DEFAULT_FILE_ACCESS_INSTRUCTIONS, DEFAULT_FILE_ACCESS_SOURCE_ID, _matches_glob, _normalize_relative_path, _run_search_with_timeout, + _slice_lines, + _split_lines_keepends, ) from .conftest import create_junction_or_skip @@ -174,7 +178,7 @@ async def test_in_memory_store_search_returns_matches_with_snippets() -> None: results = await store.search("", "error", "*.md") assert [result.file_name for result in results] == ["a.md"] matching_lines = results[0].matching_lines - assert matching_lines == [FileSearchMatch(line_number=2, line="This line has ERROR inside")] + assert matching_lines == [FileSearchMatch(line_number=2, line="This line has ERROR inside\n")] assert "ERROR" in results[0].snippet # No glob -> searches every file. @@ -314,6 +318,31 @@ async def test_filesystem_store_rejects_symlinks_into_root(tmp_path: Path) -> No assert await _list_files(store) == [] +async def test_filesystem_search_does_not_read_through_a_symlink( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A candidate that became a link after enumeration must be skipped, not read.""" + root = tmp_path / "root" + root.mkdir() + swapped = root / "notes.txt" + swapped.write_text("needle\n", encoding="utf-8") + + # Enumeration screens links itself, so both halves of the race are staged: the candidate is + # handed over as if it had passed that screen, and reports as a link by the time it is read. + # Staged rather than created, because a real symlink needs privileges this suite cannot rely on. + monkeypatch.setattr( + FileSystemAgentFileStore, + "_enumerate_search_files", + staticmethod(lambda full_dir, recursive: [("notes.txt", swapped)]), + ) + monkeypatch.setattr(_file_access_module, "is_link_or_reparse_point", lambda candidate: candidate == swapped) + + store = FileSystemAgentFileStore(root) + results = await store.search("", "needle", recursive=True) + + assert results == [] + + async def test_filesystem_store_rejects_in_root_symlinks(tmp_path: Path) -> None: """Symlinks whose target lives under the root must still be rejected. @@ -347,13 +376,51 @@ async def test_filesystem_store_search_matches_lines_and_filters_globs(tmp_path: results = await store.search("", "error", "*.md") assert [result.file_name for result in results] == ["a.md"] - assert results[0].matching_lines == [FileSearchMatch(line_number=2, line="ERROR happens")] + assert results[0].matching_lines == [FileSearchMatch(line_number=2, line="ERROR happens\n")] assert "ERROR" in results[0].snippet results_all = await store.search("", "error") assert {result.file_name for result in results_all} == {"a.md", "b.txt"} +async def test_filesystem_store_search_reports_crlf_lines_verbatim(tmp_path: Path) -> None: + """A match from a file on disk should carry the terminator the file actually has. + + The store reads search candidates as bytes for this reason: text mode would apply + universal newlines and report a CRLF line as if it ended in ``\\n``, so feeding the + match back to ``replace_lines`` would rewrite that line's ending. + """ + store = FileSystemAgentFileStore(tmp_path) + await store.write("notes.md", "alpha\r\nbeta match\r\ngamma\r\n") + + results = await store.search("", "match") + + assert results[0].matching_lines == [FileSearchMatch(line_number=2, line="beta match\r\n")] + + +async def test_filesystem_store_search_line_numbers_address_the_same_lines_as_the_editor( + tmp_path: Path, +) -> None: + """Grep's line numbers must index the same lines ``read_lines`` and ``replace_lines`` do. + + Both editors split what ``read`` returns, so any newline translation on the search path + would renumber the file underneath them — on a lone-``\\r`` file most of all, where + translation turns one line into three. + """ + store = FileSystemAgentFileStore(tmp_path) + for content in ("alpha\r\nbeta match\r\ngamma\r\n", "alpha\rbeta match\rgamma", "alpha\nbeta match\ngamma\n"): + await store.write("notes.md", content) + + results = await store.search("", "match") + line_number = results[0].matching_lines[0].line_number + raw = await store.read("notes.md") + assert raw is not None + + # The number is in range for the editor, and addresses the very line grep reported. + assert _slice_lines(raw, line_number, line_number) == [results[0].matching_lines[0].line] + assert _split_lines_keepends(raw)[line_number - 1] == results[0].matching_lines[0].line + + async def test_filesystem_store_search_is_recursive_with_root_relative_names(tmp_path: Path) -> None: """Recursive filesystem search should walk the subtree and return root-relative names.""" store = FileSystemAgentFileStore(tmp_path) @@ -484,6 +551,48 @@ def test_filesystem_store_requires_non_empty_root() -> None: FileSystemAgentFileStore(" ") +async def test_filesystem_store_rejects_a_root_that_is_a_link_before_first_use(tmp_path: Path) -> None: + """A root planted as a link before the store touches it must not be walked.""" + outside = tmp_path / "outside" + outside.mkdir() + (outside / "secret.txt").write_text("needle\n", encoding="utf-8") + root = tmp_path / "root" + + # Constructed against a path that does not exist yet, so the link is planted afterwards -- + # the lazily created root is exactly the window this covers. + store = FileSystemAgentFileStore(root) + try: + root.symlink_to(outside, target_is_directory=True) + except (OSError, NotImplementedError) as exc: + pytest.skip(f"{_NEEDS_SYMLINK}: {exc!r}") + + with pytest.raises(ValueError, match="symbolic link"): + await store.search("", "needle") + with pytest.raises(ValueError, match="symbolic link"): + await _list_files(store) + + +async def test_filesystem_store_rejects_a_root_replaced_by_a_junction(tmp_path: Path) -> None: + """A junction needs no privileges, so this is the reachable form of the same swap on Windows.""" + outside = tmp_path / "outside" + outside.mkdir() + (outside / "secret.txt").write_text("needle\n", encoding="utf-8") + root = tmp_path / "root" + root.mkdir() + + store = FileSystemAgentFileStore(root) + assert await store.search("", "needle") == [] + + # Swap the real root for a junction after construction and first use. + root.rmdir() + create_junction_or_skip(link=root, target=outside) + + with pytest.raises(ValueError, match="symbolic link or reparse point"): + await store.search("", "needle") + with pytest.raises(ValueError, match="symbolic link or reparse point"): + await _list_files(store) + + async def test_filesystem_store_does_not_create_root_until_write(tmp_path: Path) -> None: """Constructing a store must not touch the filesystem; the root is created lazily on first write.""" root = tmp_path / "does-not-exist-yet" @@ -525,6 +634,7 @@ async def test_file_access_provider_registers_tools_and_instructions( expected_names = { "file_access_write", "file_access_read", + "file_access_read_lines", "file_access_delete", "file_access_ls", "file_access_grep", @@ -558,6 +668,7 @@ async def test_file_access_provider_all_tools_require_approval( for name in ( FileAccessProvider.WRITE_TOOL_NAME, FileAccessProvider.READ_TOOL_NAME, + FileAccessProvider.READ_LINES_TOOL_NAME, FileAccessProvider.DELETE_TOOL_NAME, FileAccessProvider.LS_TOOL_NAME, FileAccessProvider.GREP_TOOL_NAME, @@ -573,6 +684,7 @@ async def test_file_access_provider_approval_opt_outs( """The approval opt-out flags flip only the affected tool group to ``never_require``.""" readonly_names = ( FileAccessProvider.READ_TOOL_NAME, + FileAccessProvider.READ_LINES_TOOL_NAME, FileAccessProvider.LS_TOOL_NAME, FileAccessProvider.GREP_TOOL_NAME, ) @@ -609,6 +721,7 @@ def test_read_only_tools_auto_approval_rule() -> None: """The read-only rule approves only the non-mutating tools.""" approved = { FileAccessProvider.READ_TOOL_NAME, + FileAccessProvider.READ_LINES_TOOL_NAME, FileAccessProvider.LS_TOOL_NAME, FileAccessProvider.GREP_TOOL_NAME, } @@ -642,6 +755,7 @@ def test_all_tools_auto_approval_rule() -> None: for name in ( FileAccessProvider.WRITE_TOOL_NAME, FileAccessProvider.READ_TOOL_NAME, + FileAccessProvider.READ_LINES_TOOL_NAME, FileAccessProvider.DELETE_TOOL_NAME, FileAccessProvider.LS_TOOL_NAME, FileAccessProvider.GREP_TOOL_NAME, @@ -798,7 +912,16 @@ def slow() -> list[FileSearchResult]: return [] with pytest.raises(ValueError, match="did not complete"): - await _run_search_with_timeout(slow) + await _run_search_with_timeout(asyncio.to_thread(slow)) + + async def slow_pipeline() -> list[FileSearchResult]: + await asyncio.sleep(0.5) + return [] + + # The same bound covers the base ``search`` pipeline, which is awaited directly + # rather than offloaded as a whole. + with pytest.raises(ValueError, match="did not complete"): + await _run_search_with_timeout(slow_pipeline()) async def test_filesystem_store_symlink_probe_fails_closed_on_oserror( @@ -899,8 +1022,8 @@ async def test_filesystem_store_search_logs_skipped_non_utf8_files( results = await store.search("", "error") assert [r.file_name for r in results] == ["notes.md"] - assert any("Skipping non-UTF-8 file during search" in rec.message for rec in caplog.records) - assert any("skipped 1 non-UTF-8 file" in rec.message for rec in caplog.records) + assert any("Skipping unreadable file during search" in rec.message for rec in caplog.records) + assert any("skipped 1 unreadable file" in rec.message for rec in caplog.records) async def test_file_access_tool_wrappers_surface_value_error_as_message( @@ -1044,6 +1167,7 @@ async def test_filesystem_store_rejects_symlinked_intermediate_directory(tmp_pat async def _prepare_access_tools( chat_client_base: SupportsChatGetResponse, *, + store: AgentFileStore | None = None, disable_write_tools: bool = False, disable_readonly_tool_approval: bool = False, disable_write_tool_approval: bool = False, @@ -1051,7 +1175,7 @@ async def _prepare_access_tools( """Prepare a FileAccessProvider and return its registered tools.""" session = AgentSession(session_id="session-1") provider = FileAccessProvider( - store=InMemoryAgentFileStore(), + store=store if store is not None else InMemoryAgentFileStore(), disable_write_tools=disable_write_tools, disable_readonly_tool_approval=disable_readonly_tool_approval, disable_write_tool_approval=disable_write_tool_approval, @@ -1173,6 +1297,187 @@ async def current() -> str: assert "Duplicate" in _text(dup[0]) +def test_slice_lines_returns_inclusive_range() -> None: + """``_slice_lines`` should slice 1-based inclusive ranges with terminators kept.""" + assert _slice_lines("a\nb\nc\n", 1, 2) == ["a\n", "b\n"] + assert _slice_lines("a\nb\nc\n", 2, 2) == ["b\n"] + + # A trailing newline yields a final empty line that is in range, matching grep/replace_lines. + assert _slice_lines("a\nb\n", 1, None) == ["a\n", "b\n", ""] + assert _slice_lines("a\nb\n", 3, 3) == [""] + + # Empty content is a single empty line, not zero lines. + assert _slice_lines("", 1, None) == [""] + + # An end_line past the last line clamps instead of failing. + assert _slice_lines("a\nb\n", 1, 99) == _slice_lines("a\nb\n", 1, None) + + # Splitting on "\n" only leaves CRLF terminators attached. + assert _slice_lines("a\r\nb", 1, 1) == ["a\r\n"] + + +def test_slice_lines_rejects_invalid_ranges() -> None: + """``_slice_lines`` should reject non-positive, inverted, and past-the-end ranges.""" + with pytest.raises(ValueError, match="start_line must be a positive integer, got 0."): + _slice_lines("a\nb\n", 0, None) + with pytest.raises(ValueError, match="end_line must be a positive integer, got 0."): + _slice_lines("a\nb\n", 1, 0) + with pytest.raises(ValueError, match=r"end_line \(2\) must not be less than start_line \(3\)."): + _slice_lines("a\nb\n", 3, 2) + with pytest.raises(ValueError, match="start_line 99 is out of range"): + _slice_lines("a\nb\n", 99, None) + + +async def test_file_access_read_lines(chat_client_base: SupportsChatGetResponse) -> None: + """``file_access_read_lines`` should return a numbered range and report bad input as a message.""" + tools = await _prepare_access_tools(chat_client_base) + save = _tool_by_name(tools, "file_access_write") + read_lines = _tool_by_name(tools, "file_access_read_lines") + + async def write(content: str) -> None: + await save.invoke(arguments={"file_name": "a.txt", "content": content, "overwrite": True}) + + async def read(**kwargs: object) -> str: + return _text((await read_lines.invoke(arguments={"file_name": "a.txt", **kwargs}))[0]) + + await write("one\ntwo\nthree\n") + assert await read(start_line=2, end_line=3) == "2\ttwo\n3\tthree\n" + + # Omitting end_line reads to the end, including the trailing empty line. + assert await read(start_line=1) == "1\tone\n2\ttwo\n3\tthree\n4\t" + + # An end_line past the last line clamps rather than reporting an error. + clamped = await read(start_line=1, end_line=999) + assert clamped == await read(start_line=1) + assert "out of range" not in clamped + + # Each line keeps its own terminator, so a CRLF line can be reused verbatim. + await write("alpha\r\nbeta\r\n") + assert await read(start_line=1, end_line=1) == "1\talpha\r\n" + + await write("one\ntwo\n") + for kwargs, expected in ( + ({"start_line": 0}, "start_line must be a positive integer"), + ({"start_line": 9}, "start_line 9 is out of range"), + ({"start_line": 2, "end_line": 1}, "must not be less than start_line"), + ): + assert expected in await read(**kwargs) + + missing = await read_lines.invoke(arguments={"file_name": "missing.txt", "start_line": 1}) + assert _text(missing[0]) == "File 'missing.txt' not found." + + +async def test_file_access_read_lines_shows_grep_line_numbers( + chat_client_base: SupportsChatGetResponse, +) -> None: + """A ``line_number`` from grep must address the same line in read_lines and replace_lines.""" + tools = await _prepare_access_tools(chat_client_base) + save = _tool_by_name(tools, "file_access_write") + read = _tool_by_name(tools, "file_access_read") + grep = _tool_by_name(tools, "file_access_grep") + read_lines = _tool_by_name(tools, "file_access_read_lines") + replace_lines = _tool_by_name(tools, "file_access_replace_lines") + + await save.invoke(arguments={"file_name": "a.txt", "content": "a\n\nc\n", "overwrite": True}) + result = await grep.invoke(arguments={"regex_pattern": "^$", "glob_pattern": "a.txt"}) + payload = json.loads(_text(result[0])) + blanks = [match["line_number"] for entry in payload for match in entry["matching_lines"]] + assert blanks == [2, 4] + + # Both blank lines grep reports are readable, including the trailing one. + for number in blanks: + shown = _text( + (await read_lines.invoke(arguments={"file_name": "a.txt", "start_line": number, "end_line": number}))[0] + ) + assert shown.removeprefix(f"{number}\t") == ("\n" if number == 2 else "") + + # The same number is then editable, closing the grep -> read -> edit loop. + edited = await replace_lines.invoke( + arguments={"file_name": "a.txt", "edits": [{"line_number": blanks[-1], "new_line": "d\n"}]} + ) + assert "out of range" not in _text(edited[0]) + assert _text((await read.invoke(arguments={"file_name": "a.txt"}))[0]) == "a\n\nc\nd\n" + + +async def test_file_access_read_lines_round_trips_into_replace_lines( + chat_client_base: SupportsChatGetResponse, +) -> None: + """The text after the gutter is literal, so a CRLF line survives a read-then-edit round trip.""" + tools = await _prepare_access_tools(chat_client_base) + save = _tool_by_name(tools, "file_access_write") + read = _tool_by_name(tools, "file_access_read") + read_lines = _tool_by_name(tools, "file_access_read_lines") + replace_lines = _tool_by_name(tools, "file_access_replace_lines") + + await save.invoke(arguments={"file_name": "a.txt", "content": "alpha\r\nbeta\r\n", "overwrite": True}) + shown = _text((await read_lines.invoke(arguments={"file_name": "a.txt", "start_line": 2, "end_line": 2}))[0]) + assert shown == "2\tbeta\r\n" + + # Rewriting the line with the text it reported keeps the CRLF terminator intact. + await replace_lines.invoke( + arguments={"file_name": "a.txt", "edits": [{"line_number": 2, "new_line": shown.removeprefix("2\t").upper()}]} + ) + assert _text((await read.invoke(arguments={"file_name": "a.txt"}))[0]) == "alpha\r\nBETA\r\n" + + +async def test_file_access_grep_reports_lines_verbatim(chat_client_base: SupportsChatGetResponse) -> None: + """A grep hit keeps its terminator, so it round-trips into replace_lines like a read_lines row.""" + tools = await _prepare_access_tools(chat_client_base) + save = _tool_by_name(tools, "file_access_write") + read = _tool_by_name(tools, "file_access_read") + grep = _tool_by_name(tools, "file_access_grep") + replace_lines = _tool_by_name(tools, "file_access_replace_lines") + + await save.invoke(arguments={"file_name": "a.txt", "content": "alpha\r\nbeta\r\n", "overwrite": True}) + found = await grep.invoke(arguments={"regex_pattern": "beta", "glob_pattern": "a.txt"}) + hit = json.loads(_text(found[0]))[0]["matching_lines"][0] + assert hit["line_number"] == 2 + assert hit["line"] == "beta\r\n" + + await replace_lines.invoke( + arguments={ + "file_name": "a.txt", + "edits": [{"line_number": hit["line_number"], "new_line": hit["line"].upper()}], + } + ) + assert _text((await read.invoke(arguments={"file_name": "a.txt"}))[0]) == "alpha\r\nBETA\r\n" + + +async def test_file_access_grep_end_anchored_pattern_matches_crlf_line( + chat_client_base: SupportsChatGetResponse, +) -> None: + r"""``$`` anchors to the end of the line's text, not to the ``\r`` of a CRLF terminator.""" + tools = await _prepare_access_tools(chat_client_base) + save = _tool_by_name(tools, "file_access_write") + grep = _tool_by_name(tools, "file_access_grep") + + await save.invoke(arguments={"file_name": "a.txt", "content": "alpha\r\nbeta match\r\n", "overwrite": True}) + found = await grep.invoke(arguments={"regex_pattern": "match$", "glob_pattern": "a.txt"}) + + hits = json.loads(_text(found[0]))[0]["matching_lines"] + assert [hit["line_number"] for hit in hits] == [2] + assert hits[0]["line"] == "beta match\r\n" + + +async def test_file_access_grep_snippet_is_anchored_at_the_match( + chat_client_base: SupportsChatGetResponse, +) -> None: + """The per-line offset must count the terminator, or every snippet drifts.""" + tools = await _prepare_access_tools(chat_client_base) + save = _tool_by_name(tools, "file_access_write") + grep = _tool_by_name(tools, "file_access_grep") + + # The first line is long enough that the snippet window is not clamped to the start of the file. + first_line = f"{'x' * 60}\r\n" + content = f"{first_line}needle\r\n" + await save.invoke(arguments={"file_name": "a.txt", "content": content, "overwrite": True}) + found = await grep.invoke(arguments={"regex_pattern": "needle", "glob_pattern": "a.txt"}) + + # The match starts right after the first line, so the window opens _SEARCH_SNIPPET_RADIUS before it. + snippet = json.loads(_text(found[0]))[0]["snippet"] + assert snippet == content[len(first_line) - _SEARCH_SNIPPET_RADIUS :] + + async def test_file_access_grep_line_numbers_are_editable(chat_client_base: SupportsChatGetResponse) -> None: """A ``line_number`` returned by ``file_access_grep`` must be in range for ``replace_lines``. @@ -1226,9 +1531,368 @@ async def test_file_access_disable_write_tools_hides_write_tools( tools = await _prepare_access_tools(chat_client_base, disable_write_tools=True) names = {getattr(tool, "name", None) for tool in tools} assert "file_access_read" in names + assert "file_access_read_lines" in names assert "file_access_ls" in names assert "file_access_grep" in names assert "file_access_write" not in names assert "file_access_delete" not in names assert "file_access_replace" not in names assert "file_access_replace_lines" not in names + + +# region AgentFileStore line-numbering contract + + +class _ContentOnlyStore(AgentFileStore): + """A store that implements only the mandatory members. + + Before the line-numbering contract this class could not exist: ``search`` was + abstract. It now inherits the base implementation and must produce line + numbers that address the same lines as ``read_lines``/``replace_lines``. + """ + + def __init__(self) -> None: + self.files: dict[str, str] = {} + + async def write(self, path: str, content: str, *, overwrite: bool = True) -> None: + self.files[path] = content + + async def read(self, path: str) -> str | None: + return self.files.get(path) + + async def delete(self, path: str) -> bool: + return self.files.pop(path, None) is not None + + async def list_children(self, directory: str = "") -> list[FileStoreEntry]: + prefix = f"{directory}/" if directory else "" + found: dict[str, str] = {} + for path in self.files: + if not path.startswith(prefix): + continue + head, _, rest = path[len(prefix) :].partition("/") + found[head] = FileStoreEntry.DIRECTORY if rest else FileStoreEntry.FILE + return [FileStoreEntry(name=name, type=kind) for name, kind in found.items()] + + async def file_exists(self, path: str) -> bool: + return path in self.files + + async def create_directory(self, path: str) -> None: + return None + + +class _AlignedOverrideStore(_ContentOnlyStore): + """Overrides ``search`` but numbers through the published primitive.""" + + async def search( + self, + directory: str, + regex_pattern: str, + glob_pattern: str | None = None, + *, + recursive: bool = False, + ) -> list[FileSearchResult]: + regex = re.compile(regex_pattern, re.IGNORECASE) + results: list[FileSearchResult] = [] + for name, content in self.files.items(): + result = AgentFileStore.scan_content(name, content, regex) + if result is not None: + results.append(result) + return results + + +def test_split_lines_is_the_published_rule() -> None: + """``AgentFileStore.split_lines`` should expose the module's own split.""" + for content in ("a\nb\n", "a\rb", "", "x", "a\r\nb\r\n", "a\x0cb\nc"): + assert AgentFileStore.split_lines(content) == _split_lines_keepends(content) + + +def test_scan_content_numbers_by_split_lines() -> None: + """``scan_content`` should report coordinates into ``split_lines``.""" + content = "alpha\r\nbeta match\r\ngamma\r\n" + result = AgentFileStore.scan_content("f.txt", content, re.compile("match", re.IGNORECASE)) + assert result is not None + match = result.matching_lines[0] + assert AgentFileStore.split_lines(content)[match.line_number - 1] == match.line + + +async def test_store_without_search_now_works_and_stays_aligned() -> None: + """A store implementing only the mandatory members gets aligned numbers for free.""" + store = _ContentOnlyStore() + raw = "header\x0cintro\nDEBUG = 1\nkeep me\nDEBUG = 2\n" + await store.write("cfg.txt", raw) + + results = await store.search("", "keep me", recursive=True) + assert len(results) == 1 + number = results[0].matching_lines[0].line_number + + # The number grep reports addresses the same line the editor will touch. + assert _slice_lines(raw, number, number) == [results[0].matching_lines[0].line] + assert _split_lines_keepends(raw)[number - 1] == "keep me\n" + + +async def test_base_traversal_walks_a_non_root_directory() -> None: + """The inherited walk must scope to ``directory`` and descend only when asked. + + The other base-search tests either sit at the root or supply their own + ``find_matching_files``, so this is what covers the default traversal a custom store + inherits: the directory prefix it joins, the descent it makes, and the names it returns. + """ + store = _ContentOnlyStore() + await store.write("docs/a.txt", "needle\n") + await store.write("docs/sub/b.txt", "needle\n") + await store.write("other/c.txt", "needle\n") + await store.write("top.txt", "needle\n") + + shallow = await store.search("docs", "needle") + deep = await store.search("docs", "needle", recursive=True) + + # Names come back relative to the searched directory, and nothing outside it is reachable. + assert sorted(result.file_name for result in shallow) == ["a.txt"] + assert sorted(result.file_name for result in deep) == ["a.txt", "sub/b.txt"] + + +async def test_base_search_reapplies_glob_and_recursion_when_a_store_over_returns() -> None: + """``find_matching_files`` may over-return; the base must not widen the caller's scope.""" + + class _OverReturningStore(_ContentOnlyStore): + async def find_matching_files( + self, + directory: str, + regex_pattern: str, + glob_pattern: str | None = None, + *, + recursive: bool = False, + ) -> list[str]: + del directory, regex_pattern, glob_pattern, recursive + return list(self.files) + + store = _OverReturningStore() + await store.write("top.md", "needle\n") + await store.write("notes.txt", "needle\n") + await store.write("nested/deep.md", "needle\n") + + only_md = await store.search("", "needle", "*.md", recursive=True) + assert sorted(result.file_name for result in only_md) == ["nested/deep.md", "top.md"] + + shallow = await store.search("", "needle", recursive=False) + assert sorted(result.file_name for result in shallow) == ["notes.txt", "top.md"] + + +async def test_base_search_skips_files_read_cannot_decode() -> None: + """One unreadable file must not abort the whole search.""" + + class _PartlyUnreadableStore(_ContentOnlyStore): + async def read(self, path: str) -> str | None: + if path == "binary.bin": + raise ValueError("not UTF-8") + return await super().read(path) + + store = _PartlyUnreadableStore() + await store.write("binary.bin", "ignored") + await store.write("good.txt", "needle\n") + + results = await store.search("", "needle", recursive=True) + assert [result.file_name for result in results] == ["good.txt"] + + +async def test_grep_accepts_an_override_that_uses_the_published_primitive( + chat_client_base: SupportsChatGetResponse, +) -> None: + """An overriding store that numbers through ``scan_content`` stays aligned with the editor.""" + store = _AlignedOverrideStore() + await store.write("cfg.txt", "header\x0cintro\nDEBUG = 1\nkeep me\nDEBUG = 2\n") + + tools = await _prepare_access_tools(chat_client_base, store=store) + grep = _tool_by_name(tools, FileAccessProvider.GREP_TOOL_NAME) + payload = json.loads(_text((await grep.invoke(arguments={"regex_pattern": "keep me"}))[0])) + + assert payload[0]["matching_lines"][0]["line_number"] == 3 + + +# endregion + +# region expected_line write guard + + +async def test_replace_lines_applies_when_expected_line_matches( + chat_client_base: SupportsChatGetResponse, +) -> None: + """A correct ``expected_line`` should not get in the way.""" + store = InMemoryAgentFileStore() + await store.write("f.txt", "one\ntwo\nthree\n") + tools = await _prepare_access_tools(chat_client_base, store=store) + replace = _tool_by_name(tools, FileAccessProvider.REPLACE_LINES_TOOL_NAME) + + await replace.invoke( + arguments={ + "file_name": "f.txt", + "edits": [{"line_number": 2, "new_line": "TWO\n", "expected_line": "two"}], + } + ) + assert await store.read("f.txt") == "one\nTWO\nthree\n" + + +async def test_replace_lines_refuses_when_expected_line_differs( + chat_client_base: SupportsChatGetResponse, +) -> None: + """A stale or mis-numbered edit must be refused, leaving the file untouched.""" + store = InMemoryAgentFileStore() + await store.write("f.txt", "one\ntwo\nthree\n") + tools = await _prepare_access_tools(chat_client_base, store=store) + replace = _tool_by_name(tools, FileAccessProvider.REPLACE_LINES_TOOL_NAME) + + result = _text( + ( + await replace.invoke( + arguments={ + "file_name": "f.txt", + "edits": [{"line_number": 3, "new_line": "X\n", "expected_line": "two"}], + } + ) + )[0] + ) + + assert "does not match the expected text" in result + assert await store.read("f.txt") == "one\ntwo\nthree\n" + + +async def test_expected_line_ignores_the_line_terminator( + chat_client_base: SupportsChatGetResponse, +) -> None: + """A grep line comes back with its terminator; passing it through must still match.""" + store = InMemoryAgentFileStore() + await store.write("f.txt", "alpha\r\nbeta\r\n") + tools = await _prepare_access_tools(chat_client_base, store=store) + replace = _tool_by_name(tools, FileAccessProvider.REPLACE_LINES_TOOL_NAME) + + await replace.invoke( + arguments={ + "file_name": "f.txt", + "edits": [{"line_number": 2, "new_line": "BETA\r\n", "expected_line": "beta\r\n"}], + } + ) + assert await store.read("f.txt") == "alpha\r\nBETA\r\n" + + +async def test_replace_lines_without_expected_line_is_unchanged( + chat_client_base: SupportsChatGetResponse, +) -> None: + """The guard is opt-in; omitting it must behave exactly as before.""" + store = InMemoryAgentFileStore() + await store.write("f.txt", "one\ntwo\n") + tools = await _prepare_access_tools(chat_client_base, store=store) + replace = _tool_by_name(tools, FileAccessProvider.REPLACE_LINES_TOOL_NAME) + + await replace.invoke(arguments={"file_name": "f.txt", "edits": [{"line_number": 1, "new_line": "ONE\n"}]}) + assert await store.read("f.txt") == "ONE\ntwo\n" + + +# endregion + + +async def test_base_search_flushes_on_candidate_count_when_files_are_empty( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Empty files add nothing to the character budget, so the count cap must flush instead.""" + store = _ContentOnlyStore() + for index in range(6): + await store.write(f"f{index}.txt", "") + + offloads: list[str] = [] + original_to_thread = asyncio.to_thread + + async def counting_to_thread(func, /, *args, **kwargs): # type: ignore[no-untyped-def] + offloads.append(getattr(func, "__name__", repr(func))) + return await original_to_thread(func, *args, **kwargs) + + monkeypatch.setattr(asyncio, "to_thread", counting_to_thread) + # Two per batch: without the count cap the character budget never advances on empty + # content, so every candidate would be retained and offloaded in a single final batch. + monkeypatch.setattr(_file_access_module, "_SCAN_BATCH_FILES", 2) + + await store.search("", "needle", recursive=True) + + assert len(offloads) == 3 + + +async def test_base_search_batches_its_scans_without_changing_results( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Batch size must control how often the scan is offloaded, and nothing else. + + The base ``search`` hands content to a worker thread in batches rather than one + file at a time, because at sub-millisecond per-file cost the thread hop dominates. + Pins both halves of that: the batching is real (offload count follows + ``_SCAN_BATCH_CHARS``) and results are identical either side of a batch boundary. + """ + store = _ContentOnlyStore() + for index in range(12): + await store.write(f"f{index}.txt", f"alpha {index}\r\nneedle here\r\ngamma\n") + + offloads: list[str] = [] + original_to_thread = asyncio.to_thread + + async def counting_to_thread(func, /, *args, **kwargs): # type: ignore[no-untyped-def] + offloads.append(getattr(func, "__name__", repr(func))) + return await original_to_thread(func, *args, **kwargs) + + monkeypatch.setattr(asyncio, "to_thread", counting_to_thread) + + # The whole corpus is far below the default batch size, so one offload. + single = await store.search("", "needle here", recursive=True) + single_offloads = len(offloads) + + # Force a batch per file and the results must not move. + offloads.clear() + monkeypatch.setattr(_file_access_module, "_SCAN_BATCH_CHARS", 1) + split = await store.search("", "needle here", recursive=True) + split_offloads = len(offloads) + + assert single_offloads == 1 + assert split_offloads == 12 + assert single == split + # And the numbering still addresses the editor's lines, terminator included. + assert all( + match.line_number == 2 and match.line == "needle here\r\n" + for result in split + for match in result.matching_lines + ) + + +async def test_expected_line_does_not_ignore_a_lone_carriage_return( + chat_client_base: SupportsChatGetResponse, +) -> None: + """A lone ``\r`` is content, so it must not be stripped away when checking ``expected_line``.""" + store = InMemoryAgentFileStore() + await store.write("f.txt", "first\nvalue\r") + tools = await _prepare_access_tools(chat_client_base, store=store) + replace = _tool_by_name(tools, FileAccessProvider.REPLACE_LINES_TOOL_NAME) + + # Line 2 is "value\r" with no \n after it, so that \r is content, not a terminator. + text = _text( + ( + await replace.invoke( + arguments={ + "file_name": "f.txt", + "edits": [{"line_number": 2, "new_line": "REPLACED\n", "expected_line": "value"}], + } + ) + )[0] + ) + + assert "does not match the expected text" in text + assert await store.read("f.txt") == "first\nvalue\r" + + +async def test_grep_matches_a_lone_carriage_return_as_content( + chat_client_base: SupportsChatGetResponse, +) -> None: + """A pattern anchored after a lone ``\r`` must still match, since the ``\r`` is not a terminator.""" + store = InMemoryAgentFileStore() + await store.write("f.txt", "alpha\r") + tools = await _prepare_access_tools(chat_client_base, store=store) + grep = _tool_by_name(tools, FileAccessProvider.GREP_TOOL_NAME) + + payload = json.loads(_text((await grep.invoke(arguments={"regex_pattern": r"alpha\r$"}))[0])) + + assert payload[0]["matching_lines"][0]["line_number"] == 1 diff --git a/python/packages/core/tests/core/test_harness_file_memory.py b/python/packages/core/tests/core/test_harness_file_memory.py index cc39850a6e6..aa416ad3cf1 100644 --- a/python/packages/core/tests/core/test_harness_file_memory.py +++ b/python/packages/core/tests/core/test_harness_file_memory.py @@ -8,6 +8,7 @@ import pytest from agent_framework import ( + AgentFileStore, AgentSession, Content, FileMemoryProvider, @@ -26,10 +27,10 @@ from agent_framework._sessions import SessionContext -def _tool_by_name(tools: list[object], name: str) -> object: +def _tool_by_name(tools: list[object], name: str) -> FunctionTool: """Return the tool with the requested name from a prepared tool list.""" for tool in tools: - if getattr(tool, "name", None) == name: + if isinstance(tool, FunctionTool) and tool.name == name: return tool raise AssertionError(f"Tool {name!r} was not found.") @@ -478,3 +479,46 @@ async def current() -> str: } ) assert "Duplicate" in _text(dup) + + +# region file-memory guards + + +async def _memory_tools(store: AgentFileStore) -> list[object]: + """Prepare a FileMemoryProvider and return its registered tools.""" + provider = FileMemoryProvider(store=store, scope="scope-1") + session = AgentSession(session_id="session-1") + context = SessionContext(input_messages=[]) + await provider.before_run(agent=None, session=session, context=context, state={}) + return list(context.tools) + + +async def test_file_memory_replace_lines_honours_expected_line() -> None: + """The write guard must work in the memory provider too, where nothing asks for approval.""" + store = InMemoryAgentFileStore() + tools = await _memory_tools(store) + write = _tool_by_name(tools, "file_memory_write") + replace = _tool_by_name(tools, "file_memory_replace_lines") + + await write.invoke(arguments={"file_name": "notes.md", "content": "one\ntwo\nthree\n"}) + + refused = _text( + await replace.invoke( + arguments={ + "file_name": "notes.md", + "edits": [{"line_number": 3, "new_line": "X\n", "expected_line": "two"}], + } + ) + ) + assert "does not match the expected text" in refused + + await replace.invoke( + arguments={ + "file_name": "notes.md", + "edits": [{"line_number": 2, "new_line": "TWO\n", "expected_line": "two"}], + } + ) + assert await store.read("scope-1/notes.md") == "one\nTWO\nthree\n" + + +# endregion diff --git a/python/samples/02-agents/harness/README.md b/python/samples/02-agents/harness/README.md index a5215a22d68..20ccc49780d 100644 --- a/python/samples/02-agents/harness/README.md +++ b/python/samples/02-agents/harness/README.md @@ -162,10 +162,10 @@ for vetting the external service, agent, skill source, or provider before enabli - **Auto-approval rules** (`FileAccessProvider.read_only_tools_auto_approval_rule` / `all_tools_auto_approval_rule`, and the equivalent `SkillsProvider` rules, passed to `ToolApprovalMiddleware`) — the built-in rules approve local tools by tool name only (e.g. - `file_access_read`, `file_access_ls`, `file_access_grep`). Auto-approval rules may match by name, - so any other local tool registered under one of these names — for example the shell tool given a - caller-configurable name — may also be auto-approved, bypassing the human approval boundary. Ensure - no other tool collides with these reserved names. + `file_access_read`, `file_access_read_lines`, `file_access_ls`, `file_access_grep`). Auto-approval + rules may match by name, so any other local tool registered under one of these names — for example + the shell tool given a caller-configurable name — may also be auto-approved, bypassing the human + approval boundary. Ensure no other tool collides with these reserved names. - **Telemetry** — when observability is enabled, telemetry destinations are developer-configured. Default telemetry is metadata only; enabling sensitive data additionally emits raw message content, tool arguments, and tool results. See the [observability samples](../observability/README.md). diff --git a/python/samples/02-agents/harness/build_your_own_claw/README.md b/python/samples/02-agents/harness/build_your_own_claw/README.md index b1c90ac784e..69a4f121373 100644 --- a/python/samples/02-agents/harness/build_your_own_claw/README.md +++ b/python/samples/02-agents/harness/build_your_own_claw/README.md @@ -78,7 +78,8 @@ Teaches the assistant to work with *your* data safely. > ⚠️ **Security — avoid tool-name collisions:** `read_only_tools_auto_approval_rule` > approves local file-access tools by tool name only (`file_access_read`, - > `file_access_ls`, `file_access_grep`). Auto-approval rules may match by name, + > `file_access_read_lines`, `file_access_ls`, `file_access_grep`). Auto-approval + > rules may match by name, > so any other local tool registered under one of these names — for example a > tool with a caller-configurable name such as the shell tool — may also be > auto-approved, bypassing the human approval boundary. Ensure no other tool From 277a823e28fe1ffba3d170ac9e31b8ddd832576d Mon Sep 17 00:00:00 2001 From: Yashvant Mahadev Hange <97538055+YashvantHange@users.noreply.github.com> Date: Tue, 8 Sep 2026 01:58:26 +0000 Subject: [PATCH 64/85] Python: carry fan-in edge buffers through checkpoint restore (#7948) * Python: carry fan-in edge buffers through checkpoint restore A fan-in buffers messages until every source has produced one. That buffer was neither checkpointed nor reset on restore, so buffered messages were lost when resuming a rebuilt workflow, and a buffer left behind by a run that failed mid-superstep survived the restore and duplicated deliveries. Edge runner delivery state now travels with the checkpoint under the reserved shared-state key _edge_state. Both restore paths reset every edge runner before reapplying what the checkpoint held, so a checkpoint written before this change resets the buffer instead of leaking it. * Python: encode edge state keys structurally Executor ids are only required to be non-empty, so joining source and target ids with "->" and "," let two different fan-in groups produce the same key: sources ['a', 'b->t,c'] and ['a->t,b', 'c'] into the same target collide. One group's buffer snapshot would then overwrite the other's, and the restore would hand both runners the same messages. JSON-encode the sorted pairs instead. * Python: cancel sibling deliveries when one fails mid-superstep @moonbox3 on PR #7948: _run_iteration's asyncio.gather calls did not cancel their other tasks when one raised, so a still-in-flight fan-in delivery could resume after restore_checkpoint/restore_from_checkpoint had already reset the FanInEdgeRunner's buffer on the same runner instance, appending a message from the failed, never-checkpointed superstep into the freshly restored buffer. Confirmed the race is real before fixing it: reproduced it with a test that fails a sibling source's delivery while another source's fan-in delivery is parked mid-flight, restores from a checkpoint taken before either message was sent, then releases the parked delivery. Without the fix it appends the stale message into the just-restored buffer, and the fan-in later fires using that stale value instead of the fresh resumed delivery. Added _gather_cancelling_siblings_on_error to replace both gather() call sites in _run_iteration, so a failing delivery cancels and awaits every other one before the exception propagates. * Python: cancel fan-out sibling deliveries too, not just _run_iteration's @moonbox3 follow-up on PR #7948: the previous fix wrapped _run_iteration's two gather() sites (across sources, and across edge runners for one source), but FanOutEdgeRunner.send_message has its own separate asyncio.gather() across one message's fan-out targets. When a fan-out has a single edge runner for its source - the common case - that inner gather is the only level where one target's failure has a sibling to race against, so the outer fix never reached it. Confirmed the race is real before fixing it: reproduced a fan-out target still executing when its sibling fails, calling ctx.send_message after restore_checkpoint has already cleared RunnerContext._messages, landing its stale output in the queue the restore reset for the resumed run. The test fails on the pre-fix code and passes after. Moved the cancel-and-await helper (renamed gather_cancelling_siblings_on_error, no longer private) into _edge_runner.py, since _runner.py already imports from there and the reverse would be circular, and used it at the fan-out gather site too. * Python: cancel WorkflowExecutor's output-forwarding siblings too Retroactive check on our own claim, per mistake #12: this PR's review reply said the other gather() sites in _workflow.py/_workflow_executor.py "hold no cross-invocation buffered state a restore can race with." Tested rather than re-argued it. _workflow.py:1034 (send_request_info_response) survives: it pops a still- pending event before writing anything, and a restore clears pending events too, so an orphaned write fails closed (raises) instead of landing. Reproduced directly against InProcRunnerContext to confirm. _workflow_executor.py's four gather() sites do not survive. ctx.send_message and ctx.yield_output/add_event write into RunnerContext state (_messages, the event queue) with no equivalent precondition check. Reproduced the same shape as the fan-in/fan-out bugs: a sub-workflow output still being forwarded when a sibling output's send fails calls ctx.send_message after a checkpoint restore has already cleared the parent's message queue, landing stale output in it. Fixed all four sites with the same gather_cancelling_siblings_on_error helper already used in _run_iteration and FanOutEdgeRunner - moved to _edge_runner.py in the prior commit specifically so _workflow_executor.py could import it without a cycle. * Python: cancel the response-delivery siblings too This was the one gather site left plain, and the reason given for leaving it was wrong. Each coroutine pops its own request id, so when one raises, its siblings still find their own events pending, and go on to write RESPONSE messages into the queue after the caller has already raised out of _send_responses_internal and skipped the entry checkpoint. That is a partially applied, unrecorded batch, which is the same corruption the other sites were converted to avoid, not a fail-closed path. What makes it safe under InProcRunnerContext today is something narrower: send_request_info_response never suspends, because its one await is send_message and that has no awaits of its own, so the siblings have always finished by the time the caller resumes. That holds for this one context implementation, not for the RunnerContext protocol, so it is recorded in the comment rather than relied on. Also note in EdgeRunner.state_key that the no-duplicate-edge guarantee it rests on comes from builder validation, which a directly constructed Workflow bypasses. --- .../agent_framework/_workflows/_checkpoint.py | 9 +- .../core/agent_framework/_workflows/_const.py | 3 + .../_workflows/_edge_runner.py | 117 +++++++- .../agent_framework/_workflows/_runner.py | 51 +++- .../agent_framework/_workflows/_workflow.py | 20 +- .../_workflows/_workflow_executor.py | 20 +- .../core/tests/workflow/test_checkpoint.py | 78 +++++ .../core/tests/workflow/test_runner.py | 274 +++++++++++++++++- .../core/tests/workflow/test_sub_workflow.py | 220 ++++++++++++++ 9 files changed, 761 insertions(+), 31 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_checkpoint.py b/python/packages/core/agent_framework/_workflows/_checkpoint.py index 62f225b04a8..d9b30d83919 100644 --- a/python/packages/core/agent_framework/_workflows/_checkpoint.py +++ b/python/packages/core/agent_framework/_workflows/_checkpoint.py @@ -52,10 +52,11 @@ class WorkflowCheckpoint: allows chaining checkpoints together to form a history of workflow states. timestamp: ISO 8601 timestamp when checkpoint was created messages: Messages exchanged between executors - state: Committed workflow state including user data and executor states. - This contains only committed state; pending state changes are not - included in checkpoints. Executor states are stored under the - reserved key '_executor_state'. + state: Committed workflow state including user data, executor states, and + edge runner delivery state. This contains only committed state; pending + state changes are not included in checkpoints. Executor states are stored + under the reserved key '_executor_state', and edge runner state, such as + partially filled fan-in buffers, under '_edge_state'. pending_request_info_events: Any pending request info events that have not yet been processed at the time of checkpointing. This allows the workflow to resume with the correct pending events after a restore. diff --git a/python/packages/core/agent_framework/_workflows/_const.py b/python/packages/core/agent_framework/_workflows/_const.py index e83025bbdc7..27b9c249617 100644 --- a/python/packages/core/agent_framework/_workflows/_const.py +++ b/python/packages/core/agent_framework/_workflows/_const.py @@ -6,6 +6,9 @@ # Key used to store executor state in state. EXECUTOR_STATE_KEY = "_executor_state" +# Key used to store edge runner delivery state (for example, fan-in buffers) in state. +EDGE_STATE_KEY = "_edge_state" + # Source identifier for internal workflow messages. INTERNAL_SOURCE_PREFIX = "internal" diff --git a/python/packages/core/agent_framework/_workflows/_edge_runner.py b/python/packages/core/agent_framework/_workflows/_edge_runner.py index c14582894b9..e3bc14e64ab 100644 --- a/python/packages/core/agent_framework/_workflows/_edge_runner.py +++ b/python/packages/core/agent_framework/_workflows/_edge_runner.py @@ -1,12 +1,14 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import json import logging from abc import ABC, abstractmethod from collections import defaultdict -from collections.abc import Callable +from collections.abc import Callable, Coroutine from typing import Any, cast +from ..exceptions import WorkflowCheckpointException from ..observability import EdgeGroupDeliveryStatus, OtelAttr, create_edge_group_processing_span from ._edge import ( Edge, @@ -24,6 +26,32 @@ logger = logging.getLogger(__name__) +async def gather_cancelling_siblings_on_error(*coroutines: Coroutine[Any, Any, Any]) -> None: + """Run coroutines concurrently; on any failure, cancel and await every other one before raising. + + Plain ``asyncio.gather()`` does not cancel its other tasks when one raises - by default they keep + running as orphaned background tasks even though the caller has already moved on with the raised + exception. For edge delivery this is a real race with checkpoint restoration: work that is still + in-flight when a sibling fails can mutate runner/context state - a ``FanInEdgeRunner``'s buffer, or + a ``RunnerContext``'s pending-message queue via a target executor's own ``ctx.send_message`` call - + *after* ``restore_checkpoint``/``restore_from_checkpoint`` has already reset that same state for the + resumed run, corrupting it with output from the failed, never-checkpointed superstep. + + Shared by :class:`RunnerImpl._run_iteration` (across sources and across edge runners for a source) + and :class:`FanOutEdgeRunner.send_message` (across one message's fan-out targets) - every point in + the delivery path where sibling coroutines run concurrently and one can fail while another is still + executing. + """ + tasks = [asyncio.ensure_future(coro) for coro in coroutines] + try: + await asyncio.gather(*tasks) + except BaseException: + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + raise + + class EdgeRunner(ABC): """Abstract base class for edge runners that handle message delivery.""" @@ -57,6 +85,42 @@ async def send_message( """ raise NotImplementedError + @property + def state_key(self) -> str: + """Return a topology-derived key identifying this runner across workflow instances. + + ``EdgeGroup.id`` defaults to a random UUID, so it differs between two instances of the + same workflow definition and cannot key state that has to survive a rebuild. Checkpoint + compatibility is decided by graph topology, so the key is derived from topology too. + Builder validation rejects two edges that share a ``source -> target`` pair anywhere in + the workflow, so no two edge groups can produce the same key. That validation runs on the + ``WorkflowBuilder`` path; a ``Workflow`` constructed directly bypasses it, and two colliding + groups would then share one buffer snapshot. Executor ids are only + required to be non-empty, so the pairs are JSON-encoded rather than joined with + separators an id could itself contain. + """ + edges = sorted([edge.source_id, edge.target_id] for edge in self._edge_group.edges) + return f"{self._edge_group.__class__.__name__}:{json.dumps(edges, separators=(',', ':'))}" + + def snapshot_state(self) -> dict[str, Any] | None: + """Capture in-flight delivery state so a checkpoint can restore it. + + Returns: + A serializable snapshot, or None when the runner holds no state to checkpoint. + """ + return None + + def restore_state(self, state: dict[str, Any] | None) -> None: + """Reset in-flight delivery state, then apply ``state`` when one was checkpointed. + + Called on every runner during checkpoint restoration, including with ``None``, so that + state left over from an interrupted run does not survive into the restored run. + + Args: + state: A snapshot previously produced by :meth:`snapshot_state`, or None to only reset. + """ + return + def _can_handle(self, executor_id: str, message: WorkflowMessage) -> bool: """Check if an executor can handle the given message data.""" if executor_id not in self._executors: @@ -278,13 +342,11 @@ async def send_message( if deliverable_edges: - async def send_to_edge(edge: Edge) -> bool: + async def send_to_edge(edge: Edge) -> None: await self._execute_on_target(edge.target_id, [edge.source_id], message, state, ctx) - return True - tasks = [send_to_edge(edge) for edge in deliverable_edges] - results = await asyncio.gather(*tasks) - return any(results) + await gather_cancelling_siblings_on_error(*(send_to_edge(edge) for edge in deliverable_edges)) + return True # If we get here, it's a broadcast message with no deliverable edges return False @@ -297,6 +359,8 @@ def _validate_selection_result(self, selection_results: list[str]) -> bool: class FanInEdgeRunner(EdgeRunner): """Runner for fan-in edge groups.""" + _BUFFER_KEY = "buffer" + def __init__(self, edge_group: FanInEdgeGroup, executors: dict[str, Executor]) -> None: super().__init__(edge_group, executors) self._edges = edge_group.edges @@ -304,6 +368,47 @@ def __init__(self, edge_group: FanInEdgeGroup, executors: dict[str, Executor]) - # Key is the source executor ID, value is a list of messages self._buffer: dict[str, list[WorkflowMessage]] = defaultdict(list) + def snapshot_state(self) -> dict[str, Any] | None: + """Capture the buffered messages that are still waiting for the remaining sources. + + A fan-in only delivers once every source has produced a message, so a superstep + boundary can fall while the buffer holds a subset of them. Those messages have + already been drained from the runner context, so the checkpoint has to carry them. + """ + buffered = {source_id: list(messages) for source_id, messages in self._buffer.items() if messages} + if not buffered: + return None + return {self._BUFFER_KEY: buffered} + + def restore_state(self, state: dict[str, Any] | None) -> None: + """Reset the buffer, then refill it from the checkpointed snapshot when there is one. + + The reset matters on its own: a run that failed mid-superstep can leave messages from + a subset of sources in the buffer, and the sources are re-executed after the restore. + Without the reset those messages would be delivered twice, and the second delivery + could fire the fan-in before the restored superstep produced all of its messages. + """ + self._buffer.clear() + if state is None: + return + + buffered: Any = state.get(self._BUFFER_KEY, {}) + if not isinstance(buffered, dict): + raise WorkflowCheckpointException( + f"Fan-in buffer for edge group {self._edge_group.id} is not a dictionary. Unable to restore." + ) + + for source_id, messages in cast(dict[Any, Any], buffered).items(): + if ( + not isinstance(source_id, str) + or not isinstance(messages, list) + or not all(isinstance(message, WorkflowMessage) for message in cast(list[Any], messages)) + ): + raise WorkflowCheckpointException( + f"Fan-in buffer for edge group {self._edge_group.id} is malformed. Unable to restore." + ) + self._buffer[source_id] = list(cast(list[WorkflowMessage], messages)) + async def send_message( self, message: WorkflowMessage, diff --git a/python/packages/core/agent_framework/_workflows/_runner.py b/python/packages/core/agent_framework/_workflows/_runner.py index ac5558dbe1c..ba8fdf188ca 100644 --- a/python/packages/core/agent_framework/_workflows/_runner.py +++ b/python/packages/core/agent_framework/_workflows/_runner.py @@ -6,16 +6,16 @@ import warnings from collections import defaultdict from collections.abc import AsyncGenerator, Sequence -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from ..exceptions import ( WorkflowCheckpointException, WorkflowConvergenceException, ) from ._checkpoint import CheckpointID, CheckpointStorage, WorkflowCheckpoint -from ._const import EXECUTOR_STATE_KEY +from ._const import EDGE_STATE_KEY, EXECUTOR_STATE_KEY from ._edge import EdgeGroup -from ._edge_runner import EdgeRunner, create_edge_runner +from ._edge_runner import EdgeRunner, create_edge_runner, gather_cancelling_siblings_on_error from ._events import WorkflowEvent from ._executor import Executor from ._runner_context import ( @@ -70,6 +70,9 @@ def __init__( self._executors = executors self._edge_runners = [create_edge_runner(group, executors) for group in edge_groups] self._edge_runner_map = self._parse_edge_runners(self._edge_runners) + # Keyed by topology rather than EdgeGroup.id, which is a random UUID: a checkpoint has to + # restore onto a rebuilt instance of the same workflow definition. + self._edge_runner_state_keys = [runner.state_key for runner in self._edge_runners] self._ctx = ctx self._workflow_name = workflow_name self._graph_signature_hash = graph_signature_hash @@ -219,14 +222,14 @@ async def _deliver_messages_for_edge_runner(edge_runner: EdgeRunner) -> None: return tasks = [_deliver_messages_for_edge_runner(edge_runner) for edge_runner in associated_edge_runners] - await asyncio.gather(*tasks) + await gather_cancelling_siblings_on_error(*tasks) message_batches = await self._ctx.drain_messages() tasks = [ _deliver_messages(source_executor_id, source_messages) for source_executor_id, source_messages in message_batches.items() ] - await asyncio.gather(*tasks) + await gather_cancelling_siblings_on_error(*tasks) async def _prepare_checkpoint_state(self) -> None: """Persist executor snapshots into committed shared state. @@ -235,6 +238,7 @@ async def _prepare_checkpoint_state(self) -> None: state payload without necessarily writing to a checkpoint storage backend. """ await self._save_executor_states() + self._save_edge_runner_states() self._state.commit() async def create_checkpoint_if_enabled(self) -> None: @@ -325,6 +329,8 @@ async def restore_from_checkpoint( self._state.import_state(checkpoint.state) # Restore executor states using the restored state await self._restore_executor_states() + # Restore edge runner states, resetting any left over from an interrupted run + self._restore_edge_runner_states() # Apply the checkpoint to the context await self._ctx.apply_checkpoint(checkpoint) # Mark the runner as resumed @@ -382,6 +388,7 @@ async def restore_checkpoint(self, checkpoint: WorkflowCheckpoint) -> None: self._state.clear() self._state.import_state(checkpoint.state) await self._restore_executor_states() + self._restore_edge_runner_states() await self._ctx.apply_checkpoint(checkpoint) self._mark_resumed(checkpoint) except Exception as e: @@ -428,6 +435,40 @@ async def _restore_executor_states(self) -> None: except Exception as ex: # pragma: no cover - defensive raise WorkflowCheckpointException(f"Executor {executor_id} on_checkpoint_restore failed") from ex + def _save_edge_runner_states(self) -> None: + """Store edge runner delivery state, such as partially filled fan-in buffers, in state. + + A fan-in buffers messages until every source has produced one, and the runner context has + already drained those messages, so a checkpoint that omits them loses them on restore. + """ + edge_states: dict[str, dict[str, Any]] = {} + for key, runner in zip(self._edge_runner_state_keys, self._edge_runners, strict=True): + snapshot = runner.snapshot_state() + if snapshot is not None: + edge_states[key] = snapshot + + if edge_states: + self._state.set(EDGE_STATE_KEY, edge_states) + elif self._state.has(EDGE_STATE_KEY): + self._state.delete(EDGE_STATE_KEY) + + def _restore_edge_runner_states(self) -> None: + """Reset every edge runner and reapply the delivery state held by the restored checkpoint. + + Every runner is reset, including runners the checkpoint has no entry for, so that state + left behind by an interrupted run cannot survive into the restored run. + """ + stored: Any = self._state.get(EDGE_STATE_KEY, {}) + if not isinstance(stored, dict): + raise WorkflowCheckpointException("Edge states in shared state is not a dictionary. Unable to restore.") + edge_states = cast(dict[Any, Any], stored) + + for key, runner in zip(self._edge_runner_state_keys, self._edge_runners, strict=True): + state: Any = edge_states.get(key) + if state is not None and not isinstance(state, dict): + raise WorkflowCheckpointException(f"Edge state for {key} is not a dictionary. Unable to restore.") + runner.restore_state(cast("dict[str, Any] | None", state)) + def _parse_edge_runners(self, edge_runners: list[EdgeRunner]) -> dict[str, list[EdgeRunner]]: """Parse the edge runners of the workflow into a mapping where each source executor ID maps to its edge runners. diff --git a/python/packages/core/agent_framework/_workflows/_workflow.py b/python/packages/core/agent_framework/_workflows/_workflow.py index 65be066e063..ab89df1e5b3 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow.py +++ b/python/packages/core/agent_framework/_workflows/_workflow.py @@ -3,7 +3,6 @@ # ruff:file-ignore[unnecessary-assign-before-yield] from __future__ import annotations -import asyncio import functools import hashlib import json @@ -27,6 +26,7 @@ EdgeGroup, FanOutEdgeGroup, ) +from ._edge_runner import gather_cancelling_siblings_on_error from ._events import ( WorkflowErrorDetails, WorkflowEvent, @@ -1043,10 +1043,20 @@ async def _send_responses_internal(self, responses: Mapping[str, Any]) -> None: ) coerced_responses[request_id] = response - await asyncio.gather(*[ - self._runner.context.send_request_info_response(request_id, response) - for request_id, response in coerced_responses.items() - ]) + # Cancelling siblings on error, like every other concurrent write into runner state. Each + # coroutine pops its own request id, so a sibling of a failing one still finds its own event + # pending and would go on to write a RESPONSE message into the queue after the caller had + # already raised out of this method and skipped the entry checkpoint below. Under + # InProcRunnerContext that cannot happen today only because send_request_info_response never + # suspends -- its one await, send_message, has no awaits of its own -- so the siblings are + # always already finished. That is a property of this one context implementation, not of the + # RunnerContext protocol, so it is not what this is relying on. + await gather_cancelling_siblings_on_error( + *( + self._runner.context.send_request_info_response(request_id, response) + for request_id, response in coerced_responses.items() + ) + ) # Record a response-entry checkpoint capturing the delivered responses in-flight, before the # runner processes them in the next superstep. This mirrors the initial-message entry diff --git a/python/packages/core/agent_framework/_workflows/_workflow_executor.py b/python/packages/core/agent_framework/_workflows/_workflow_executor.py index f3bccd1ff15..fce77a540c8 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_executor.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_executor.py @@ -1,6 +1,5 @@ # Copyright (c) Microsoft. All rights reserved. -import asyncio import logging import sys import types @@ -11,6 +10,7 @@ from ._workflow import Workflow from ._const import GLOBAL_KWARGS_KEY, WORKFLOW_RUN_KWARGS_KEY +from ._edge_runner import gather_cancelling_siblings_on_error from ._events import ( WorkflowEvent, WorkflowRunState, @@ -501,10 +501,12 @@ async def on_checkpoint_restore(self, state: dict[str, Any]) -> None: self.id, execution_context.execution_id, ) - await asyncio.gather(*[ - self.workflow._runner_context.add_request_info_event(event) # pyright: ignore[reportPrivateUsage] - for event in request_info_events - ]) + await gather_cancelling_siblings_on_error( + *( + self.workflow._runner_context.add_request_info_event(event) # pyright: ignore[reportPrivateUsage] + for event in request_info_events + ) + ) async def _process_workflow_result( self, @@ -535,9 +537,9 @@ async def _process_workflow_result( # Process outputs if self.allow_direct_output: # Note that the executor is allowed to continue its own execution after yielding outputs. - await asyncio.gather(*[ctx.yield_output(output) for output in outputs]) + await gather_cancelling_siblings_on_error(*(ctx.yield_output(output) for output in outputs)) else: - await asyncio.gather(*[ctx.send_message(output) for output in outputs]) + await gather_cancelling_siblings_on_error(*(ctx.send_message(output) for output in outputs)) # Pipe sub-workflow intermediate emissions up through the parent's event stream. # Bypasses the parent's yield-output classifier so the 'intermediate' label is preserved @@ -550,7 +552,9 @@ async def _forward_intermediate_output(output: Any) -> None: event = WorkflowEvent("intermediate", executor_id=self.id, data=output) await ctx.add_event(event) - await asyncio.gather(*[_forward_intermediate_output(output) for output in intermediate_outputs]) + await gather_cancelling_siblings_on_error( + *(_forward_intermediate_output(output) for output in intermediate_outputs) + ) # Process request info events for event in request_info_events: diff --git a/python/packages/core/tests/workflow/test_checkpoint.py b/python/packages/core/tests/workflow/test_checkpoint.py index 5f3da78cd1d..be8de7c13b6 100644 --- a/python/packages/core/tests/workflow/test_checkpoint.py +++ b/python/packages/core/tests/workflow/test_checkpoint.py @@ -1080,6 +1080,84 @@ async def test_memory_checkpoint_storage_roundtrip_empty_collections(): assert loaded.pending_request_info_events == {} +async def test_workflow_resume_restores_partially_filled_fan_in_buffer(): + """A fan-in still waiting on a source must keep the messages it already holds across a resume. + + ``fast`` reaches the fan-in one superstep before ``slow``. At that boundary its message + lives only in the fan-in buffer - the runner context drained it on delivery - so a + checkpoint that omits the buffer strands the workflow with a fan-in that never fires. + """ + from typing_extensions import Never + + from agent_framework import WorkflowBuilder, WorkflowContext, handler + from agent_framework._workflows._const import EDGE_STATE_KEY + from agent_framework._workflows._executor import Executor + + class Dispatcher(Executor): + @handler + async def dispatch(self, message: str, ctx: WorkflowContext[str]) -> None: + await ctx.send_message(message) + + class Fast(Executor): + @handler + async def run(self, message: str, ctx: WorkflowContext[str]) -> None: + await ctx.send_message(f"{message}-fast") + + class Relay(Executor): + @handler + async def run(self, message: str, ctx: WorkflowContext[str]) -> None: + await ctx.send_message(message) + + class Slow(Executor): + @handler + async def run(self, message: str, ctx: WorkflowContext[str]) -> None: + await ctx.send_message(f"{message}-slow") + + class Joiner(Executor): + @handler + async def join(self, messages: list[str], ctx: WorkflowContext[Never, str]) -> None: # type: ignore[valid-type] + await ctx.yield_output(", ".join(sorted(messages))) + + storage = InMemoryCheckpointStorage() + + def _build_workflow() -> Any: + dispatcher = Dispatcher(id="dispatcher") + fast = Fast(id="fast") + relay = Relay(id="relay") + slow = Slow(id="slow") + joiner = Joiner(id="joiner") + return ( + WorkflowBuilder( + name="fan-in-resume-test", + max_iterations=10, + start_executor=dispatcher, + checkpoint_storage=storage, + ) + .add_edge(dispatcher, fast) + .add_edge(dispatcher, relay) + .add_edge(relay, slow) + .add_fan_in_edges([fast, slow], joiner) + .build() + ) + + workflow = _build_workflow() + workflow_name = workflow.name + outputs = (await workflow.run("seed")).get_outputs() + assert outputs == ["seed-fast, seed-slow"] + + checkpoints = await storage.list_checkpoints(workflow_name=workflow_name) + buffered = [checkpoint for checkpoint in checkpoints if EDGE_STATE_KEY in checkpoint.state] + assert len(buffered) == 1, ( + f"Exactly one superstep boundary should fall while the fan-in holds only the fast branch, got {len(buffered)}" + ) + + # Resume on a fresh instance of the same workflow definition, which has new edge group ids. + resumed_workflow = _build_workflow() + resumed_outputs = (await resumed_workflow.run(checkpoint_id=buffered[0].checkpoint_id)).get_outputs() + + assert resumed_outputs == ["seed-fast, seed-slow"] + + # endregion # region FileCheckpointStorage diff --git a/python/packages/core/tests/workflow/test_runner.py b/python/packages/core/tests/workflow/test_runner.py index 2299c382432..b6f9bc531ef 100644 --- a/python/packages/core/tests/workflow/test_runner.py +++ b/python/packages/core/tests/workflow/test_runner.py @@ -2,7 +2,7 @@ import asyncio from dataclasses import dataclass -from typing import Any +from typing import Any, cast from unittest.mock import AsyncMock, MagicMock import pytest @@ -20,8 +20,9 @@ WorkflowRunState, handler, ) -from agent_framework._workflows._const import EXECUTOR_STATE_KEY -from agent_framework._workflows._edge import FanOutEdgeGroup, SingleEdgeGroup +from agent_framework._workflows._const import EDGE_STATE_KEY, EXECUTOR_STATE_KEY +from agent_framework._workflows._edge import FanInEdgeGroup, FanOutEdgeGroup, SingleEdgeGroup +from agent_framework._workflows._edge_runner import FanInEdgeRunner from agent_framework._workflows._runner import Runner from agent_framework._workflows._runner_context import ( InProcRunnerContext, @@ -566,6 +567,273 @@ async def on_checkpoint_restore(self, state: dict[str, Any]) -> None: assert runner._previous_checkpoint_id == checkpoint.checkpoint_id # pyright: ignore[reportPrivateUsage] +class CollectingExecutor(Executor): + """A fan-in target that records every aggregated batch it receives.""" + + def __init__(self, id: str) -> None: + super().__init__(id=id) + self.batches: list[list[int]] = [] + + @handler + async def collect(self, messages: list[MockMessage], ctx: WorkflowContext[Any, int]) -> None: + self.batches.append([message.data for message in messages]) + + +def _fan_in_runner(target: CollectingExecutor) -> tuple[Runner, InProcRunnerContext]: + """Build a runner for a two-source fan-in into ``target``.""" + source_a = MockExecutor(id="source_a") + source_b = MockExecutor(id="source_b") + edge_group = FanInEdgeGroup([source_a.id, source_b.id], target.id) + executors: dict[str, Executor] = {source_a.id: source_a, source_b.id: source_b, target.id: target} + ctx = InProcRunnerContext() + + return Runner([edge_group], executors, State(), ctx, "test_name", graph_signature_hash="test_hash"), ctx + + +def test_edge_runner_state_key_distinguishes_ids_containing_separators(): + """Executor ids are only required to be non-empty, so the key cannot join them with separators. + + Both groups below hold three edges into ``t`` and differ only in where the source ids place + ``->`` and ``,``. Sharing a key would make one group's buffer overwrite the other's. + """ + group_a = FanInEdgeGroup(["a", "b->t,c"], "t") + group_b = FanInEdgeGroup(["a->t,b", "c"], "t") + + key_a = FanInEdgeRunner(group_a, {}).state_key + key_b = FanInEdgeRunner(group_b, {}).state_key + + assert key_a != key_b + + +async def test_runner_checkpoint_roundtrips_partially_filled_fan_in_buffer(): + """A fan-in holding messages from a subset of its sources must checkpoint and restore them. + + The runner context has already drained those messages, so the checkpoint is the only + place they still exist. A rebuilt workflow gets fresh ``EdgeGroup`` ids, so the restore + also has to find the buffer without relying on them. + """ + target = CollectingExecutor(id="target") + runner, ctx = _fan_in_runner(target) + + await ctx.send_message(WorkflowMessage(data=MockMessage(data=1), source_id="source_a")) + await runner._run_iteration() # pyright: ignore[reportPrivateUsage] + + # The fan-in is still waiting on source_b, so nothing has reached the target yet. + assert target.batches == [] + + checkpoint = await runner.build_checkpoint() + assert EDGE_STATE_KEY in checkpoint.state + + # Resume on a fresh instance of the same workflow definition. + restored_target = CollectingExecutor(id="target") + restored_runner, restored_ctx = _fan_in_runner(restored_target) + await restored_runner.restore_checkpoint(checkpoint) + + await restored_ctx.send_message(WorkflowMessage(data=MockMessage(data=2), source_id="source_b")) + await restored_runner._run_iteration() # pyright: ignore[reportPrivateUsage] + + assert restored_target.batches == [[1, 2]] + + +async def test_runner_restore_clears_fan_in_buffer_left_by_an_interrupted_run(): + """Messages buffered after the restored checkpoint must not survive the restore. + + Their sources are re-executed once the run resumes, so keeping them would deliver the + same message twice and could fire the fan-in before the resumed superstep produced all + of its messages. + """ + target = CollectingExecutor(id="target") + runner, ctx = _fan_in_runner(target) + + # Captured while the fan-in buffer is empty, so the checkpoint carries no edge state at all - + # the same shape as a checkpoint written before edge state was captured. + checkpoint = await runner.build_checkpoint() + assert EDGE_STATE_KEY not in checkpoint.state + + # A superstep that fails after the fan-in buffered source_a leaves that message behind. + await ctx.send_message(WorkflowMessage(data=MockMessage(data=1), source_id="source_a")) + await runner._run_iteration() # pyright: ignore[reportPrivateUsage] + assert target.batches == [] + + await runner.restore_checkpoint(checkpoint) + + # Both sources are re-executed after the resume; the target must see each message once. + await ctx.send_message(WorkflowMessage(data=MockMessage(data=1), source_id="source_a")) + await ctx.send_message(WorkflowMessage(data=MockMessage(data=2), source_id="source_b")) + await runner._run_iteration() # pyright: ignore[reportPrivateUsage] + + assert target.batches == [[1, 2]] + + +async def test_runner_orphaned_delivery_cannot_repopulate_a_restored_fan_in_buffer(): + """A sibling delivery still in flight when another source fails must not survive into a restore. + + Reported on PR #7953 (@moonbox3): ``_run_iteration``'s ``asyncio.gather`` calls do not cancel their + other tasks when one raises - the others keep running as orphaned background tasks. If a fan-in + delivery is one of those orphans, it can resume after ``restore_checkpoint`` has already reset the + ``FanInEdgeRunner``'s buffer for the *next* run and append into it, so the buffer ends up holding a + message from the failed, never-checkpointed superstep. Once the caller redelivers that same source + as part of the normal resume flow, the fan-in can fire using the stale message instead of - or + alongside - the fresh redelivery. + + The pre-restore (stale) and post-restore (fresh) ``source_b`` messages deliberately carry + different payloads (``99`` vs ``2``) so a fan-in that fires on the stale message is + distinguishable from one that correctly waits for the fresh redelivery; using the same payload + for both would let a buggy run and a correct run produce an identical-looking batch by + coincidence. + """ + target = CollectingExecutor(id="target") + source_a = MockExecutor(id="source_a") + source_b = MockExecutor(id="source_b") + source_c = MockExecutor(id="source_c") + fan_in_group = FanInEdgeGroup([source_a.id, source_b.id], target.id) + executors: dict[str, Executor] = { + source_a.id: source_a, + source_b.id: source_b, + source_c.id: source_c, + target.id: target, + } + ctx = InProcRunnerContext() + runner = Runner([fan_in_group], executors, State(), ctx, "test_name", graph_signature_hash="test_hash") + + real_fan_in_runner = cast(FanInEdgeRunner, runner._edge_runner_map["source_a"][0]) # pyright: ignore[reportPrivateUsage] + assert runner._edge_runner_map["source_b"][0] is real_fan_in_runner # pyright: ignore[reportPrivateUsage] + + entered_delay = asyncio.Event() + release = asyncio.Event() + + class DelayedFanInDelivery: + """Wraps the real fan-in runner's delivery for source_b, pausing before it actually appends.""" + + async def send_message(self, message: WorkflowMessage, state: State, ctx: RunnerContext) -> bool: + entered_delay.set() + await release.wait() + return await real_fan_in_runner.send_message(message, state, ctx) + + class FailingDelivery: + async def send_message(self, message: WorkflowMessage, state: State, ctx: RunnerContext) -> bool: + raise RuntimeError("source_c delivery failed") + + runner._edge_runner_map["source_b"] = [DelayedFanInDelivery()] # type: ignore[assignment, list-item] # pyright: ignore[reportPrivateUsage] # ty: ignore[invalid-assignment] + runner._edge_runner_map["source_c"] = [FailingDelivery()] # type: ignore[assignment, list-item] # pyright: ignore[reportPrivateUsage] # ty: ignore[invalid-assignment] + + # A checkpoint from before this superstep started: empty fan-in buffer, matching the last + # known-good superstep boundary that a real resume flow would restore to. + checkpoint = await runner.build_checkpoint() + assert EDGE_STATE_KEY not in checkpoint.state + + await ctx.send_message(WorkflowMessage(data=MockMessage(data=1), source_id="source_a")) + await ctx.send_message(WorkflowMessage(data=MockMessage(data=99), source_id="source_b")) + await ctx.send_message(WorkflowMessage(data=MockMessage(data=-1), source_id="source_c")) + + iteration_task = asyncio.create_task(runner._run_iteration()) # pyright: ignore[reportPrivateUsage] + + # source_a's delivery completes immediately; source_b's is parked mid-flight, about to append + # into the *same* FanInEdgeRunner instance, when source_c's delivery fails. + await entered_delay.wait() + + with pytest.raises(RuntimeError, match="source_c delivery failed"): + await iteration_task + + # The app's resume flow restores the same runner from the last good checkpoint - this is exactly + # what a caller does after catching the failure above, per Workflow._execute_with_message_or_checkpoint. + await runner.restore_checkpoint(checkpoint) + assert real_fan_in_runner._buffer == {} # pyright: ignore[reportPrivateUsage] + + # Release the delayed delivery. If _run_iteration cancelled it when source_c failed (the fix), + # `release.wait()` raises CancelledError here and the real fan-in runner's send_message for the + # stale data=99 message is never reached at all; releasing an already-cancelled waiter is a no-op. + release.set() + await asyncio.sleep(0) # yield so the orphaned task either appends or finishes cancelling + + # The stale data=99 message from the failed, never-checkpointed superstep must not have reached + # the buffer the restore just reset for the next run. + assert real_fan_in_runner._buffer.get("source_b", []) == [] # pyright: ignore[reportPrivateUsage] + + # The resume flow redelivers the failed superstep's sources with the correct payloads, as the + # existing test_runner_restore_clears_fan_in_buffer_left_by_an_interrupted_run models. source_b's + # fresh message (data=2) differs from the stale one (data=99) precisely so the assertion below can + # tell which one the fan-in actually used. + await ctx.send_message(WorkflowMessage(data=MockMessage(data=1), source_id="source_a")) + await ctx.send_message(WorkflowMessage(data=MockMessage(data=2), source_id="source_b")) + await runner._run_iteration() # pyright: ignore[reportPrivateUsage] + + # The target must see the fresh redelivery (source_a=1, source_b=2), not the stale pre-restore + # message (99) that the orphaned delivery left behind, and not both. + assert target.batches == [[1, 2]] + + +async def test_runner_orphaned_fan_out_target_cannot_repopulate_a_restored_message_queue(): + """The same orphaned-task race applies one level deeper, inside FanOutEdgeRunner.send_message. + + Follow-up from @moonbox3 on PR #7948: ``_gather_cancelling_siblings_on_error`` wraps the two + ``gather()`` call sites in ``_run_iteration``, but ``FanOutEdgeRunner.send_message`` has its own, + separate ``asyncio.gather()`` across a single message's fan-out targets (``_edge_runner.py:322``). + When a fan-out has only one edge runner for its source (the common case), that inner gather is + the *only* level at which one target's failure has a sibling target to race against - the outer + per-source/per-edge-runner levels my fix wraps see just one task each, so there is nothing for + the fix to cancel there. A target still executing when a sibling target fails can call + ``WorkflowContext.send_message`` (via its own handler) after ``restore_checkpoint`` has already + cleared ``RunnerContext._messages`` for the resumed run, repopulating it with output from the + failed, never-checkpointed superstep. + """ + entered_delay = asyncio.Event() + release = asyncio.Event() + + class FailingTarget(Executor): + @handler + async def handle(self, message: MockMessage, ctx: WorkflowContext[Any, int]) -> None: + raise RuntimeError("target1 failed") + + class BlockingTarget(Executor): + """Still executing when its fan-out sibling fails; emits a message once released.""" + + @handler + async def handle(self, message: MockMessage, ctx: WorkflowContext[Any, int]) -> None: + entered_delay.set() + await release.wait() + # A stale output from the failed superstep, sent after the sibling's failure surfaced. + await ctx.send_message(MockMessage(data=999)) + + source = MockExecutor(id="source") + target1 = FailingTarget(id="target1") + target2 = BlockingTarget(id="target2") + edge_group = FanOutEdgeGroup(source_id=source.id, target_ids=[target1.id, target2.id]) + executors: dict[str, Executor] = {source.id: source, target1.id: target1, target2.id: target2} + ctx = InProcRunnerContext() + runner = Runner([edge_group], executors, State(), ctx, "test_name", graph_signature_hash="test_hash") + + # A checkpoint from before this superstep started: no in-flight messages, matching the last + # known-good superstep boundary a real resume flow would restore to. + checkpoint = await runner.build_checkpoint() + assert not checkpoint.messages + + await ctx.send_message(WorkflowMessage(data=MockMessage(data=1), source_id=source.id)) + + iteration_task = asyncio.create_task(runner._run_iteration()) # pyright: ignore[reportPrivateUsage] + + # target2 is now parked mid-handler, about to call ctx.send_message once released, when target1 + # raises inside the same FanOutEdgeRunner.send_message call's own internal gather. + await entered_delay.wait() + + with pytest.raises(RuntimeError, match="target1 failed"): + await iteration_task + + # The app's resume flow restores the same runner from the last good checkpoint. + await runner.restore_checkpoint(checkpoint) + assert not await ctx.has_messages() + + # Release the parked target. If _run_iteration's fix reached this inner gather, target2's task + # would already be cancelled and this is a no-op; if it does not, target2 proceeds to call + # ctx.send_message with its stale output. + release.set() + await asyncio.sleep(0) # yield so the orphaned target's send_message actually runs before we continue + + # The message queue the restore just reset for the next run must not have picked up output from + # the failed, never-checkpointed superstep. + assert not await ctx.has_messages() + + async def test_runner_build_checkpoint_includes_in_flight_messages(): """build_checkpoint() must snapshot in-flight messages non-destructively.""" executor = MockExecutor(id="executor_a") diff --git a/python/packages/core/tests/workflow/test_sub_workflow.py b/python/packages/core/tests/workflow/test_sub_workflow.py index f320146e3f5..f4ca5b38190 100644 --- a/python/packages/core/tests/workflow/test_sub_workflow.py +++ b/python/packages/core/tests/workflow/test_sub_workflow.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +import asyncio import logging from dataclasses import dataclass, field from typing import Any @@ -14,14 +15,18 @@ SubWorkflowResponseMessage, Workflow, WorkflowBuilder, + WorkflowCheckpoint, WorkflowContext, WorkflowEvent, WorkflowExecutor, + WorkflowRunResult, WorkflowRunState, handler, response_handler, ) from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage +from agent_framework._workflows._runner_context import InProcRunnerContext, WorkflowMessage +from agent_framework._workflows._state import State # Test message types @@ -731,6 +736,148 @@ async def test_sub_workflow_checkpoint_restore_preserves_sub_workflow_state() -> assert result.get_final_state() == WorkflowRunState.IDLE +@dataclass +class FanInResult: + """Result yielded by the fan-in sub-workflow.""" + + value: str + + +class FanInSubWorkflowFast(Executor): + """Sub-workflow branch that reaches the fan-in immediately.""" + + def __init__(self) -> None: + super().__init__(id="fan_in_fast") + + @handler + async def run(self, message: str, ctx: WorkflowContext[str]) -> None: + await ctx.send_message(f"{message}-fast") + + +class FanInSubWorkflowAsk(Executor): + """Sub-workflow branch that pauses on a request before reaching the fan-in.""" + + def __init__(self) -> None: + super().__init__(id="fan_in_ask") + + @handler + async def run(self, message: str, ctx: WorkflowContext) -> None: + await ctx.request_info(request_data=CheckpointRequest(prompt=message), response_type=str) + + @response_handler + async def handle_response( + self, + original_request: CheckpointRequest, + response: str, + ctx: WorkflowContext[str], + ) -> None: + await ctx.send_message(response) + + +class FanInSubWorkflowJoiner(Executor): + """Sub-workflow fan-in target.""" + + def __init__(self) -> None: + super().__init__(id="fan_in_joiner") + + @handler + async def join(self, messages: list[str], ctx: WorkflowContext[Never, FanInResult]) -> None: # type: ignore[valid-type] + await ctx.yield_output(FanInResult(value=", ".join(sorted(messages)))) + + +class FanInCheckpointCoordinator(Executor): + """Parent coordinator that forwards the sub-workflow's request and yields its result.""" + + def __init__(self) -> None: + super().__init__(id="fan_in_coordinator") + self._pending_requests: dict[str, SubWorkflowRequestMessage] = {} + + @handler + async def start(self, value: str, ctx: WorkflowContext[str]) -> None: + await ctx.send_message(value) + + @handler + async def handle_sub_workflow_request(self, request: SubWorkflowRequestMessage, ctx: WorkflowContext) -> None: + data = request.source_event.data + if isinstance(data, CheckpointRequest): + self._pending_requests[data.id] = request + await ctx.request_info(data, str) + + @handler + async def handle_sub_workflow_result(self, result: FanInResult, ctx: WorkflowContext[Never, str]) -> None: # type: ignore[valid-type] + await ctx.yield_output(result.value) + + @response_handler + async def handle_response( + self, + original_request: CheckpointRequest, + response: str, + ctx: WorkflowContext[SubWorkflowResponseMessage], + ) -> None: + sub_request = self._pending_requests.pop(original_request.id, None) + if sub_request is None: + raise ValueError(f"No pending request for ID: {original_request.id}") + await ctx.send_message(sub_request.create_response(response)) + + async def on_checkpoint_save(self) -> dict[str, Any]: + return {"pending_requests": self._pending_requests} + + async def on_checkpoint_restore(self, state: dict[str, Any]) -> None: + self._pending_requests = state.get("pending_requests", {}) + + +def _build_fan_in_sub_workflow(storage: InMemoryCheckpointStorage) -> Workflow: + """Build a parent workflow whose sub-workflow pauses with a half-filled fan-in.""" + start = FanInSubWorkflowFast() + ask = FanInSubWorkflowAsk() + joiner = FanInSubWorkflowJoiner() + sub_workflow = ( + WorkflowBuilder(start_executor=start).add_edge(start, ask).add_fan_in_edges([start, ask], joiner).build() + ) + sub_workflow_executor = WorkflowExecutor(sub_workflow, id="fan_in_sub_workflow_executor") + + coordinator = FanInCheckpointCoordinator() + return ( + WorkflowBuilder(start_executor=coordinator, checkpoint_storage=storage) + .add_edge(coordinator, sub_workflow_executor) + .add_edge(sub_workflow_executor, coordinator) + .build() + ) + + +async def test_sub_workflow_checkpoint_restore_preserves_partially_filled_fan_in() -> None: + """A sub-workflow that pauses with a half-filled fan-in must keep those messages on resume. + + The sub-workflow's own checkpoint is embedded in the parent checkpoint, so the fast + branch's message - already drained out of the sub-workflow's runner context and into the + fan-in buffer - only survives if the buffer travels with that checkpoint. + """ + storage = InMemoryCheckpointStorage() + + workflow1 = _build_fan_in_sub_workflow(storage) + request_id: str | None = None + async for event in workflow1.run("seed", stream=True): + if event.type == "request_info": + request_id = event.request_id + assert request_id is not None + + checkpoints = await storage.list_checkpoints(workflow_name=workflow1.name) + checkpoint_id = max(checkpoints, key=lambda cp: cp.iteration_count).checkpoint_id + + # Resume on a fresh instance, whose sub-workflow has new edge group ids, then answer the + # request so the second branch finally reaches the fan-in. + workflow2 = _build_fan_in_sub_workflow(storage) + resumed_request_id: str | None = None + async for event in workflow2.run(checkpoint_id=checkpoint_id, stream=True): + if event.type == "request_info": + resumed_request_id = event.request_id + assert resumed_request_id is not None + + result = await workflow2.run(responses={resumed_request_id: "answered"}) + + assert result.get_outputs() == ["answered, seed-fast"] + + async def test_sub_workflow_intermediate_outputs_propagate_to_parent() -> None: """A child workflow's intermediate emissions must bubble up through the parent. @@ -801,3 +948,76 @@ async def run(self, message: str, ctx: WorkflowContext[Never, str]) -> None: # # The parent's own terminal output is unaffected. assert any(e.executor_id == "parent_sink" and e.data == "final: hello" for e in output_events) + + +async def test_workflow_executor_orphaned_output_forward_cannot_repopulate_a_restored_message_queue() -> None: + """The orphaned-task race from PR #7948 applies to WorkflowExecutor's own output-forwarding gather too. + + That PR's review excluded ``_workflow_executor.py``'s ``gather()`` sites with "none of them hold + cross-invocation buffered state a restore can race with" - that reasoning was wrong. When + ``allow_direct_output`` is false (the default), ``_process_workflow_result`` forwards every + sub-workflow output to the parent by gathering ``ctx.send_message(output)`` per output. + ``WorkflowContext.send_message`` reaches straight into ``RunnerContext._messages`` with no + precondition check (unlike, say, ``send_request_info_response``'s pop-and-validate, which turned + out to protect a sibling gather site in ``_workflow.py``). A sibling output still being sent when + another fails is therefore not cancelled by plain ``asyncio.gather``, and can append into the + parent's message queue after a checkpoint restore has already cleared it for the resumed run. + """ + + class _Inner(Executor): + @handler + async def handle(self, message: str, ctx: WorkflowContext[str]) -> None: + pass + + inner_workflow = WorkflowBuilder(start_executor=_Inner(id="inner")).build() + workflow_executor = WorkflowExecutor(inner_workflow, id="wrapped") + + ctx_impl = InProcRunnerContext() + state = State() + wctx: WorkflowContext[Any] = WorkflowContext(workflow_executor, ["parent_source"], state, ctx_impl) + + entered_delay = asyncio.Event() + release = asyncio.Event() + real_send_message = ctx_impl.send_message + + async def patched_send_message(message: WorkflowMessage) -> None: + if message.data == "bad-output": + raise RuntimeError("output send failed") + if message.data == "stale-output-from-failed-run": + entered_delay.set() + await release.wait() + await real_send_message(message) + + ctx_impl.send_message = patched_send_message # type: ignore[method-assign] # ty: ignore[invalid-assignment] + + # allow_direct_output defaults to False, so outputs go through the ctx.send_message branch. + # A status event is required for get_final_state(), which _process_workflow_result reads before + # the gather; without it the method raises immediately and entered_delay is never set. + result = WorkflowRunResult( + [ + WorkflowEvent("output", data="bad-output"), + WorkflowEvent("output", data="stale-output-from-failed-run"), + ], + status_events=[WorkflowEvent.status(WorkflowRunState.IDLE)], + ) + + task = asyncio.create_task(workflow_executor._process_workflow_result(result, wctx)) # pyright: ignore[reportPrivateUsage] + + # The second output's send is now parked mid-flight when the first one raises. + await asyncio.wait_for(entered_delay.wait(), timeout=5) + + with pytest.raises(RuntimeError, match="output send failed"): + await task + + # The parent restores the same runner context from its last good checkpoint - what a caller does + # after catching a failed superstep, per Workflow._execute_with_message_or_checkpoint. + checkpoint = WorkflowCheckpoint(workflow_name="test_name", graph_signature_hash="test_hash") + await ctx_impl.apply_checkpoint(checkpoint) + assert not await ctx_impl.has_messages() + + # Release the parked send. If the fix reached this gather, its task was already cancelled and + # this is a no-op; if it did not, the stale output lands in the just-restored queue. + release.set() + await asyncio.sleep(0) + + assert not await ctx_impl.has_messages() From 1575b1a3259612f5ffaebebd1c756ad1e3f6e11a Mon Sep 17 00:00:00 2001 From: JHf0912 <2677569277@qq.com> Date: Tue, 8 Sep 2026 05:50:28 +0000 Subject: [PATCH 65/85] Python: fix(core): include tool trajectory details in summarizer input (#8087) * refactor(core): scaffold structured summary content rendering Introduce _format_summary_content as the dispatch point for per-content summary rendering and rewrite _format_summary_message to combine structured renderings with Message.text, keeping the legacy fallback to content types. Behavior is byte-identical for existing inputs; tool-call and tool-result branches follow in the next commit. * fix(core): include function call details in summarizer input Render function_call contents with name, arguments, and call id, and function_result contents with result text, exception, and call id in _format_summary_content, so the summarizer LLM sees the tool trajectory instead of a bare content type. Text-only messages keep their legacy rendering byte-for-byte; messages mixing text and tool contents now include both. * fix(core): include MCP tool and approval details in summarizer input Extend _format_summary_content with mcp_server_tool_call / mcp_server_tool_result (tool name, arguments, output, call id) and function_approval_request / function_approval_response (nested call name, approval id, decision) branches, completing the tool trajectory visible to the summarizer LLM. Fixes #8086 * fix(core): harden summary content rendering edge cases - Stringify non-JSON-serializable MCP result mappings with the same json.dumps(default=str) fallback used by Content.from_function_result, preventing compaction crashes inside _select_summary_input_groups. - Preserve time order of text and structured contents in _format_summary_message by emitting consecutive text blocks in place. - Use tool_name as the fallback identity for MCP tool calls nested in approval contents. Addresses review feedback on PR #8087; adds three regression tests. * fix(core): render rich result items in summarizer input Prefer content.items when rendering function_result for the summarizer transcript, falling back to content.result for legacy results. Rich results (such as the error contents emitted by Monty and Hyperlight) store their payload in items while result only carries concatenated text, so failures previously rendered as blank results and the summarizer lost the error details. Adds a dedicated _format_summary_result_items helper rendering text and error items (message, error_details, error_code). Adds three regression tests; all 93 tests in tests/core/test_compaction.py pass; ruff clean. Addresses review feedback on PR #8087. * test(core): add strategy-level tool trajectory regression test Add test_summarization_strategy_preserves_tool_trajectory_in_summary_input, matching the issue #8086 triage reproduction: a recording summarizer runs over a tool call/result pair with a named function, JSON arguments, and a shared call id, and the summarizer transcript must carry the tool name, arguments, result, and call id. Red against main (transcript contains only function_call / function_result placeholders) and green with the summarizer input fix. All 94 tests in tests/core/test_compaction.py pass; ruff clean. * fix(core): render rich item metadata in summarizer input Non-text rich result items (data, uri, hosted_file) now render safe metadata in the summarizer transcript instead of being skipped, so a tool that returns only an output file no longer reaches the summarizer as an empty function_result. Data contents never embed their payload: they are represented by media type and additional_properties path, while external uri contents include their uri, matching the existing observability stance of not emitting binary blobs. Adds three regression tests; all 97 tests in tests/core/test_compaction.py pass; ruff clean. Addresses review feedback on PR #8087. --- .../core/agent_framework/_compaction.py | 115 +++++- .../core/tests/core/test_compaction.py | 338 ++++++++++++++++++ 2 files changed, 451 insertions(+), 2 deletions(-) diff --git a/python/packages/core/agent_framework/_compaction.py b/python/packages/core/agent_framework/_compaction.py index c7a65e80d33..b9080d0a814 100644 --- a/python/packages/core/agent_framework/_compaction.py +++ b/python/packages/core/agent_framework/_compaction.py @@ -1198,12 +1198,123 @@ def _tool_result_text(value: Any) -> str: if text_parts: return "\n".join(text_parts) if isinstance(value, Mapping): - return json.dumps(cast(Mapping[str, object], value), ensure_ascii=False) + try: + return json.dumps(cast(Mapping[str, object], value), ensure_ascii=False, default=str) + except (TypeError, ValueError): + return str(cast(object, value)) return str(cast(object, value)) +def _format_summary_item_metadata(item: Content) -> str: + """Render safe metadata for non-text rich result items. + + Never embeds binary payloads: data contents carry their bytes as a + base64 data URI and are represented by media type and path only. + """ + details: list[str] = [] + if item.media_type: + details.append(f"media_type={item.media_type}") + if item.type == "uri" and item.uri: + details.append(f"uri={item.uri}") + if item.type == "data": + path = item.additional_properties.get("path") + if isinstance(path, str) and path: + details.append(f"path={path}") + if item.file_id: + details.append(f"file_id={item.file_id}") + if item.name: + details.append(f"name={item.name}") + return f"{item.type} content" + (f" ({', '.join(details)})" if details else "") + + +def _format_summary_result_items(items: Sequence[Content]) -> str: + """Render function_result items (text and error contents) for the summarizer transcript. + + Rich results store their payload in ``items`` (for example the error + contents emitted by Monty and Hyperlight) while ``result`` only carries + the concatenated text. Returns an empty string when nothing is renderable + so callers can fall back to ``result``. + """ + parts: list[str] = [] + for item in items: + if item.type == "text" and item.text: + parts.append(item.text) + elif item.type == "error": + label = item.message or "" + if item.error_details: + label = f"{label}: {item.error_details}" if label else item.error_details + if item.error_code: + label = f"error({item.error_code}): {label}" + if label: + parts.append(label) + else: + label = _format_summary_item_metadata(item) + if label: + parts.append(label) + return "\n".join(parts) + + +def _format_summary_content(content: Content) -> str: + """Render one content item for the summarizer input transcript. + + Tool calls and results are rendered with their name, arguments, result + text, and call id so the summarizer sees the tool trajectory instead of a + bare content type. Text contents are aggregated via ``Message.text`` + instead. Returns an empty string when the item has no structured rendering + of its own, so callers can fall back to the legacy rendering. + """ + if content.type == "function_call": + arguments = _tool_result_text(content.arguments) if content.arguments is not None else "" + call = f"function_call {content.name or ''}({arguments})" + if content.call_id: + call += f" [call_id={content.call_id}]" + return call + if content.type == "function_result": + result_text = _format_summary_result_items(content.items) if content.items else "" + if not result_text: + result_text = _tool_result_text(content.result) if content.result is not None else "no result" + if content.exception: + result_text = f"error({content.exception}): {result_text}" + call_id_suffix = f" [call_id={content.call_id}]" if content.call_id else "" + return f"function_result: {result_text}{call_id_suffix}" + if content.type == "mcp_server_tool_call": + arguments = _tool_result_text(content.arguments) if content.arguments is not None else "" + call = f"mcp_tool_call {content.tool_name or ''}({arguments})" + if content.call_id: + call += f" [call_id={content.call_id}]" + return call + if content.type == "mcp_server_tool_result": + result_text = _tool_result_text(content.output) + if content.exception: + result_text = f"error({content.exception}): {result_text}" + call_id_suffix = f" [call_id={content.call_id}]" if content.call_id else "" + return f"mcp_tool_result: {result_text}{call_id_suffix}" + if content.type in ("function_approval_request", "function_approval_response"): + nested_call = content.function_call + name = "" if nested_call is None else nested_call.name or nested_call.tool_name or "" + label = "approval_request" if content.type == "function_approval_request" else "approval_response" + rendered = f"{label}: {name} [id={content.id}]" + if content.type == "function_approval_response": + rendered += f" approved={content.approved}" + return rendered + return "" + + def _format_summary_message(index: int, message: Message) -> str: - content_text = message.text + parts: list[str] = [] + pending_text: list[str] = [] + for content in message.contents: + rendered = _format_summary_content(content) + if rendered: + if pending_text: + parts.append(" ".join(pending_text)) + pending_text = [] + parts.append(rendered) + elif content.type == "text" and content.text: + pending_text.append(content.text) + if pending_text: + parts.append(" ".join(pending_text)) + content_text = "; ".join(parts) if not content_text: content_text = ", ".join(content.type for content in message.contents) return f"{index}. [{message.role}] {content_text}" diff --git a/python/packages/core/tests/core/test_compaction.py b/python/packages/core/tests/core/test_compaction.py index b0e82ae94fc..2cfadff942b 100644 --- a/python/packages/core/tests/core/test_compaction.py +++ b/python/packages/core/tests/core/test_compaction.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from datetime import date from typing import Any import pytest @@ -36,6 +37,7 @@ included_token_count, ) from agent_framework._compaction import ( + _format_summary_message, _select_summary_input_groups, _serialize_message, append_compaction_message, @@ -914,6 +916,35 @@ async def test_summarization_strategy_bounds_summary_input_to_complete_groups() assert oversized_message.message_id not in summarized_message_ids +async def test_summarization_strategy_preserves_tool_trajectory_in_summary_input() -> None: + summarizer = _RecordingSummarizer() + messages = [ + Message(role="user", contents=["use the tool"]), + _assistant_function_call("call_1"), + _tool_result("call_1", "ok"), + Message(role="assistant", contents=["tool completed"]), + Message(role="user", contents=["what next"]), + Message(role="assistant", contents=["here is the follow-up"]), + ] + strategy = SummarizationStrategy( + client=summarizer, # type: ignore[arg-type] # pyrefly: ignore[bad-argument-type] # ty: ignore[invalid-argument-type] + target_count=2, + threshold=0, + ) + annotate_message_groups(messages) + + changed = await strategy(messages) + + assert changed is True + assert len(summarizer.requests) == 1 + summary_request_text = summarizer.requests[0][1].text + assert summary_request_text is not None + assert "tool" in summary_request_text + assert '{"value":"x"}' in summary_request_text + assert "[call_id=call_1]" in summary_request_text + assert "ok" in summary_request_text + + async def test_summarization_strategy_skips_oversized_first_group() -> None: summarizer = _RecordingSummarizer() messages = [ @@ -972,6 +1003,313 @@ def test_summary_input_selection_does_not_retokenize_selected_transcript() -> No ) +def test_format_summary_message_includes_function_call_details() -> None: + message = Message( + role="assistant", + contents=[Content.from_function_call(call_id="call_1", name="get_weather", arguments='{"city":"Seattle"}')], + ) + + rendered = _format_summary_message(1, message) + + assert "get_weather" in rendered + assert '{"city":"Seattle"}' in rendered + assert "[call_id=call_1]" in rendered + + +def test_format_summary_message_includes_function_result_and_exception() -> None: + message = Message( + role="tool", + contents=[Content.from_function_result(call_id="call_1", result="42", exception="ValueError")], + ) + + rendered = _format_summary_message(2, message) + + assert "function_result" in rendered + assert "42" in rendered + assert "error(ValueError)" in rendered + assert "[call_id=call_1]" in rendered + + +def test_format_summary_message_renders_function_result_without_call_id() -> None: + message = Message( + role="tool", + contents=[Content("function_result", call_id=None, result="done")], + ) + + rendered = _format_summary_message(3, message) + + assert "done" in rendered + assert "call_id" not in rendered + + +def test_format_summary_message_combines_tool_calls_with_text() -> None: + message = Message( + role="assistant", + contents=[ + "I'll check the weather.", + Content.from_function_call(call_id="call_1", name="get_weather", arguments='{"city":"Seattle"}'), + ], + ) + + rendered = _format_summary_message(4, message) + + assert "I'll check the weather." in rendered + assert "get_weather" in rendered + + +def test_format_summary_message_preserves_text_only_messages() -> None: + message = Message(role="user", contents=["hello world"]) + + rendered = _format_summary_message(5, message) + + assert rendered == "5. [user] hello world" + + +def test_format_summary_message_includes_mcp_tool_details() -> None: + message = Message( + role="assistant", + contents=[ + Content.from_mcp_server_tool_call( + call_id="mcp_1", + tool_name="search", + server_name="test_server", + arguments='{"query":"x"}', + ), + Content.from_mcp_server_tool_result( + call_id="mcp_1", + output=[Content.from_text("found")], + ), + ], + ) + + rendered = _format_summary_message(6, message) + + assert "search" in rendered + assert '{"query":"x"}' in rendered + assert "[call_id=mcp_1]" in rendered + assert "found" in rendered + + +def test_format_summary_message_includes_approval_request() -> None: + message = Message( + role="assistant", + contents=[ + Content.from_function_approval_request( + id="approval_1", + function_call=Content.from_function_call( + call_id="call_1", name="send_email", arguments='{"to":"a@b.c"}' + ), + ) + ], + ) + + rendered = _format_summary_message(7, message) + + assert "approval_request" in rendered + assert "send_email" in rendered + assert "[id=approval_1]" in rendered + + +def test_format_summary_message_includes_approval_response() -> None: + message = Message( + role="assistant", + contents=[ + Content.from_function_approval_response( + approved=True, + id="approval_1", + function_call=Content.from_function_call( + call_id="call_1", name="send_email", arguments='{"to":"a@b.c"}' + ), + ) + ], + ) + + rendered = _format_summary_message(8, message) + + assert "approval_response" in rendered + assert "approved=True" in rendered + + +def test_format_summary_message_stringifies_non_json_mcp_result_without_crash() -> None: + message = Message( + role="tool", + contents=[Content("mcp_server_tool_result", call_id="mcp_1", output={"when": date(2026, 1, 1)})], + ) + + rendered = _format_summary_message(9, message) + + assert "2026" in rendered + assert "[call_id=mcp_1]" in rendered + + +def test_format_summary_message_preserves_time_order_for_mixed_contents() -> None: + message = Message( + role="assistant", + contents=[ + "I'll check the weather.", + Content.from_function_call(call_id="call_1", name="get_weather", arguments='{"city":"Seattle"}'), + "Please wait.", + ], + ) + + rendered = _format_summary_message(10, message) + + assert rendered.index("I'll check the weather.") < rendered.index("function_call") + assert rendered.index("function_call") < rendered.index("Please wait.") + + +def test_format_summary_message_uses_tool_name_for_mcp_approval() -> None: + message = Message( + role="assistant", + contents=[ + Content.from_function_approval_request( + id="approval_mcp_1", + function_call=Content.from_mcp_server_tool_call( + call_id="mcp_1", + tool_name="search", + server_name="test_server", + arguments='{"query":"x"}', + ), + ) + ], + ) + + rendered = _format_summary_message(11, message) + + assert "approval_request" in rendered + assert "search" in rendered + assert "[id=approval_mcp_1]" in rendered + + +def test_format_summary_message_renders_error_items_for_rich_results() -> None: + message = Message( + role="tool", + contents=[ + Content( + "function_result", + call_id="call_err_1", + result="", + items=[Content.from_error(message="Execution error", error_details="ValueError: boom")], + ) + ], + ) + + rendered = _format_summary_message(12, message) + + assert "Execution error" in rendered + assert "ValueError: boom" in rendered + assert "[call_id=call_err_1]" in rendered + + +def test_format_summary_message_falls_back_to_result_when_items_render_empty() -> None: + message = Message( + role="tool", + contents=[ + Content( + "function_result", + call_id="call_fb_1", + result="fallback result", + items=[Content.from_error()], + ) + ], + ) + + rendered = _format_summary_message(13, message) + + assert "fallback result" in rendered + assert "[call_id=call_fb_1]" in rendered + + +def test_format_summary_message_renders_mixed_text_and_error_items() -> None: + message = Message( + role="tool", + contents=[ + Content( + "function_result", + call_id="call_mix_1", + result="", + items=[ + Content.from_text("partial output"), + Content.from_error(message="Execution error", error_details="sandbox timed out"), + ], + ) + ], + ) + + rendered = _format_summary_message(14, message) + + assert "partial output" in rendered + assert "Execution error" in rendered + assert "sandbox timed out" in rendered + + +def test_format_summary_message_renders_metadata_for_data_items() -> None: + data_item = Content.from_data( + b"some bytes", + media_type="application/octet-stream", + additional_properties={"path": "/output/result.png"}, + ) + message = Message( + role="tool", + contents=[ + Content( + "function_result", + call_id="call_data_1", + result="", + items=[data_item], + ) + ], + ) + + rendered = _format_summary_message(15, message) + + assert "data content" in rendered + assert "media_type=application/octet-stream" in rendered + assert "path=/output/result.png" in rendered + assert data_item.uri is not None + assert data_item.uri not in rendered + + +def test_format_summary_message_renders_metadata_for_uri_items() -> None: + message = Message( + role="tool", + contents=[ + Content( + "function_result", + call_id="call_uri_1", + result="", + items=[Content.from_uri("https://example.com/result.png", media_type="image/png")], + ) + ], + ) + + rendered = _format_summary_message(16, message) + + assert "uri content" in rendered + assert "uri=https://example.com/result.png" in rendered + assert "media_type=image/png" in rendered + + +def test_format_summary_message_renders_metadata_for_hosted_file_items() -> None: + message = Message( + role="tool", + contents=[ + Content( + "function_result", + call_id="call_file_1", + result="", + items=[Content.from_hosted_file(file_id="file_1", media_type="text/plain", name="out.txt")], + ) + ], + ) + + rendered = _format_summary_message(17, message) + + assert "hosted_file content" in rendered + assert "file_id=file_1" in rendered + assert "name=out.txt" in rendered + + async def test_summarization_strategy_returns_false_when_summary_generation_fails( caplog: Any, ) -> None: From c928018c154fb425b9c597ac4ad0dce15eb33b68 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 05:58:24 +0000 Subject: [PATCH 66/85] Chore(deps): Bump astral-sh/setup-uv in /.github/actions/python-setup (#8069) Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 9.0.0 to 10.0.1. - [Release notes](https://github.com/astral-sh/setup-uv/releases) - [Commits](https://github.com/astral-sh/setup-uv/compare/c771a70e6277c0a99b617c7a806ffedaca235ff9...20cfd1bf945f4377ade1205e4dbc17946fc9a30d) --- updated-dependencies: - dependency-name: astral-sh/setup-uv dependency-version: 10.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/python-setup/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/python-setup/action.yml b/.github/actions/python-setup/action.yml index d682ba04b2d..079e87c8122 100644 --- a/.github/actions/python-setup/action.yml +++ b/.github/actions/python-setup/action.yml @@ -17,7 +17,7 @@ runs: using: "composite" steps: - name: Set up uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: version-file: "python/pyproject.toml" enable-cache: true From 135d85c1b88d4ce849b4e6dd7b41ef4cce2e717a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 05:59:03 +0000 Subject: [PATCH 67/85] Bump Azure.Monitor.OpenTelemetry.Exporter from 1.5.0 to 1.8.3 (#7887) --- updated-dependencies: - dependency-name: Azure.Monitor.OpenTelemetry.Exporter dependency-version: 1.8.3 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- dotnet/Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 94e08a91c3b..99d2ba7cef3 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -33,7 +33,7 @@ - + From 4174ebbeecbcf3b04d98dd59c79978c1dfc76395 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 05:59:21 +0000 Subject: [PATCH 68/85] Bump actions/setup-dotnet from 5.2.0 to 6.0.0 (#7584) Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 5.2.0 to 6.0.0. - [Release notes](https://github.com/actions/setup-dotnet/releases) - [Commits](https://github.com/actions/setup-dotnet/compare/c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7...a98b56852c35b8e3190ac28c8c2271da59106c68) --- updated-dependencies: - dependency-name: actions/setup-dotnet dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> --- .github/workflows/dotnet-build-and-test.yml | 6 +++--- .github/workflows/dotnet-integration-tests.yml | 2 +- .github/workflows/dotnet-verify-samples.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/dotnet-build-and-test.yml b/.github/workflows/dotnet-build-and-test.yml index 4c90100af1c..d9ddaba3b9d 100644 --- a/.github/workflows/dotnet-build-and-test.yml +++ b/.github/workflows/dotnet-build-and-test.yml @@ -123,7 +123,7 @@ jobs: uses: ./.github/actions/free-runner-disk-space - name: Setup dotnet - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: ${{ github.workspace }}/dotnet/global.json @@ -350,7 +350,7 @@ jobs: exit 1 - name: Setup dotnet - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: ${{ github.workspace }}/dotnet/global.json @@ -545,7 +545,7 @@ jobs: uses: ./.github/actions/free-runner-disk-space - name: Setup dotnet - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: ${{ github.workspace }}/dotnet/global.json diff --git a/.github/workflows/dotnet-integration-tests.yml b/.github/workflows/dotnet-integration-tests.yml index 3bbdfb2f3c4..032f5dabcee 100644 --- a/.github/workflows/dotnet-integration-tests.yml +++ b/.github/workflows/dotnet-integration-tests.yml @@ -64,7 +64,7 @@ jobs: echo "COSMOS_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV - name: Setup dotnet - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: ${{ github.workspace }}/dotnet/global.json diff --git a/.github/workflows/dotnet-verify-samples.yml b/.github/workflows/dotnet-verify-samples.yml index e24c4233275..49865915ea8 100644 --- a/.github/workflows/dotnet-verify-samples.yml +++ b/.github/workflows/dotnet-verify-samples.yml @@ -52,7 +52,7 @@ jobs: declarative-agents - name: Setup dotnet - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: ${{ github.workspace }}/dotnet/global.json From e9c31ab0b531b1bcfbc63c88d2152812f7a4133f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 06:00:55 +0000 Subject: [PATCH 69/85] Bump pmeier/pytest-results-action from 0.7.2 to 0.9.0 (#7583) Bumps [pmeier/pytest-results-action](https://github.com/pmeier/pytest-results-action) from 0.7.2 to 0.9.0. - [Release notes](https://github.com/pmeier/pytest-results-action/releases) - [Commits](https://github.com/pmeier/pytest-results-action/compare/20b595761ba9bf89e115e875f8bc863f913bc8ad...fdc7f18d9934e38aca411ca9557e6577bd25ca9c) --- updated-dependencies: - dependency-name: pmeier/pytest-results-action dependency-version: 0.9.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/python-lab-tests.yml | 2 +- .github/workflows/python-merge-tests.yml | 16 ++++++++-------- .github/workflows/python-tests.yml | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/python-lab-tests.yml b/.github/workflows/python-lab-tests.yml index 9a0034ab8e8..47c9568fd92 100644 --- a/.github/workflows/python-lab-tests.yml +++ b/.github/workflows/python-lab-tests.yml @@ -95,7 +95,7 @@ jobs: # Surface failing tests - name: Surface failing tests if: always() - uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2 + uses: pmeier/pytest-results-action@fdc7f18d9934e38aca411ca9557e6577bd25ca9c # v0.9.0 with: path: ./python/packages/lab/**.xml summary: true diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml index 70278b4b80f..683331796af 100644 --- a/.github/workflows/python-merge-tests.yml +++ b/.github/workflows/python-merge-tests.yml @@ -124,7 +124,7 @@ jobs: working-directory: ./python - name: Surface failing tests if: always() - uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2 + uses: pmeier/pytest-results-action@fdc7f18d9934e38aca411ca9557e6577bd25ca9c # v0.9.0 with: path: ./python/pytest.xml summary: true @@ -178,7 +178,7 @@ jobs: working-directory: ./python - name: Surface failing tests if: always() - uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2 + uses: pmeier/pytest-results-action@fdc7f18d9934e38aca411ca9557e6577bd25ca9c # v0.9.0 with: path: ./python/pytest.xml summary: true @@ -248,7 +248,7 @@ jobs: working-directory: ./python - name: Surface failing tests if: always() - uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2 + uses: pmeier/pytest-results-action@fdc7f18d9934e38aca411ca9557e6577bd25ca9c # v0.9.0 with: path: ./python/pytest.xml summary: true @@ -373,7 +373,7 @@ jobs: kill -KILL -- "-$server_pid" 2>/dev/null || kill -KILL "$server_pid" 2>/dev/null || true - name: Surface failing tests if: always() - uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2 + uses: pmeier/pytest-results-action@fdc7f18d9934e38aca411ca9557e6577bd25ca9c # v0.9.0 with: path: ./python/pytest.xml summary: true @@ -440,7 +440,7 @@ jobs: working-directory: ./python - name: Surface failing tests if: always() - uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2 + uses: pmeier/pytest-results-action@fdc7f18d9934e38aca411ca9557e6577bd25ca9c # v0.9.0 with: path: ./python/pytest.xml summary: true @@ -501,7 +501,7 @@ jobs: working-directory: ./python - name: Surface failing tests if: always() - uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2 + uses: pmeier/pytest-results-action@fdc7f18d9934e38aca411ca9557e6577bd25ca9c # v0.9.0 with: path: ./python/pytest.xml summary: true @@ -568,7 +568,7 @@ jobs: working-directory: ./python - name: Surface failing tests if: always() - uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2 + uses: pmeier/pytest-results-action@fdc7f18d9934e38aca411ca9557e6577bd25ca9c # v0.9.0 with: path: ./python/pytest.xml summary: true @@ -623,7 +623,7 @@ jobs: --junitxml=pytest.xml - name: Surface failing tests if: always() - uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2 + uses: pmeier/pytest-results-action@fdc7f18d9934e38aca411ca9557e6577bd25ca9c # v0.9.0 with: path: ./python/pytest.xml summary: true diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index eae7b1af58d..4ccfe254ae0 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -50,7 +50,7 @@ jobs: # Surface failing tests - name: Surface failing tests if: always() - uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2 + uses: pmeier/pytest-results-action@fdc7f18d9934e38aca411ca9557e6577bd25ca9c # v0.9.0 with: path: ./python/pytest.xml summary: true From 62bca5a4ea099c603059919d677796e8073d7745 Mon Sep 17 00:00:00 2001 From: Xiaoping Liao <106010272+kyletser@users.noreply.github.com> Date: Tue, 8 Sep 2026 06:15:52 +0000 Subject: [PATCH 70/85] Python: fix declarative workflow DevUI message input (#7839) * Fix declarative DevUI message input * fix(declarative): isolate fresh DevUI message state * test(declarative): select pending checkpoint deterministically --- .../_workflows/_declarative_base.py | 34 ++-- .../_workflows/_executors_control_flow.py | 1 + .../declarative/tests/test_graph_coverage.py | 14 ++ .../tests/test_workflow_factory.py | 153 ++++++++++++++++++ .../devui/workflow_declarative/workflow.yaml | 34 ++-- 5 files changed, 197 insertions(+), 39 deletions(-) diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py index f4567578853..b640fe00b7e 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py @@ -1084,6 +1084,8 @@ async def _ensure_state_initialized( Follows .NET's DefaultTransform pattern - accepts any input type: - dict/Mapping: Used directly as workflow.inputs - str: Converted to {"input": value} + - Message: Starts a fresh run (e.g. from DevUI), with text and ID + surfaced through Inputs.input and System.LastMessage*. - list[Message]: Treated as the agent-facing message contract (e.g. from WorkflowAgent / as_agent()). The prior conversation history is stored in ``Conversation.messages``/ @@ -1113,26 +1115,18 @@ async def _ensure_state_initialized( if isinstance(trigger, dict): # Structured inputs - use directly state.initialize(trigger) # type: ignore - elif isinstance(trigger, list) and all(isinstance(m, Message) for m in trigger): # pyright: ignore[reportUnknownVariableType] - # list[Message] (e.g. from WorkflowAgent / as_agent()). - messages_list = cast(list[Message], trigger) - - # Detect continuation: if the workflow's shared state already - # carries declarative data from a prior turn (because the host - # restored a checkpoint and dispatched this run with - # reset_context=False), we MUST NOT call state.initialize() - - # that would wipe Conversation.messages, Local.*, System.* etc. - # Instead, treat the trigger as the new turn's user input only: - # update Inputs.input, append the new user message to existing - # Conversation history, and refresh System.LastMessage*. - # - # Continuation = declarative state already exists in the workflow's - # shared state (either left over in-memory from a prior turn on - # the same instance, or restored from a checkpoint just before - # this run). In that case state.initialize() would wipe Local.*, - # System.*, Conversation.* etc., destroying the cross-turn - # context we're trying to preserve. - is_continuation = state.is_initialized() + elif isinstance(trigger, Message) or ( + isinstance(trigger, list) and all(isinstance(m, Message) for m in trigger) # pyright: ignore[reportUnknownVariableType] + ): + # Message (e.g. from DevUI) or list[Message] (e.g. from WorkflowAgent / as_agent()). + messages_list = [trigger] if isinstance(trigger, Message) else cast(list[Message], trigger) + + # Preserve the existing list[Message] continuation contract used + # by WorkflowAgent. A bare Message starts a fresh run: DevUI can + # reuse this workflow instance across unrelated conversations. + # State left on the instance is not evidence of session ownership. + # Explicit checkpoint/HIL restores use the runner's resume path. + is_continuation = not isinstance(trigger, Message) and state.is_initialized() # Locate the trailing user message in the trigger. last_user_index = -1 diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_control_flow.py b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_control_flow.py index 6aca5682e75..5db554574f4 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_control_flow.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_control_flow.py @@ -400,6 +400,7 @@ async def handle_action( self, trigger: dict[str, Any] | str + | Message | list[Message] | ActionTrigger | ActionComplete diff --git a/python/packages/declarative/tests/test_graph_coverage.py b/python/packages/declarative/tests/test_graph_coverage.py index ec83d94a1aa..90a72c0c3da 100644 --- a/python/packages/declarative/tests/test_graph_coverage.py +++ b/python/packages/declarative/tests/test_graph_coverage.py @@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from agent_framework import Message from agent_framework_declarative._workflows import ( ActionComplete, @@ -1522,6 +1523,19 @@ async def test_ensure_state_initialized_with_string_input(self, mock_context, mo inputs = state.get("Workflow.Inputs") assert inputs == {"input": "string trigger"} + async def test_ensure_state_initialized_with_message_input(self, mock_context, mock_state): + """Test _ensure_state_initialized with a single Message input.""" + from agent_framework_declarative._workflows._executors_control_flow import JoinExecutor + + executor = JoinExecutor({"kind": "Entry"}) + message = Message(role="user", contents=["message trigger"], message_id="message-1") + await executor.handle_action(message, mock_context) + + state = DeclarativeWorkflowState(mock_state) + assert state.get("Workflow.Inputs") == {"input": "message trigger"} + assert state.get("System.LastMessage") == {"Text": "message trigger", "Id": "message-1"} + assert state.get("System.LastMessageText") == "message trigger" + async def test_ensure_state_initialized_with_custom_object(self, mock_context, mock_state): """Test _ensure_state_initialized with custom object converts to string.""" from agent_framework_declarative._workflows._executors_basic import ( diff --git a/python/packages/declarative/tests/test_workflow_factory.py b/python/packages/declarative/tests/test_workflow_factory.py index c40633dae1b..b170753794f 100644 --- a/python/packages/declarative/tests/test_workflow_factory.py +++ b/python/packages/declarative/tests/test_workflow_factory.py @@ -2,10 +2,12 @@ """Unit tests for WorkflowFactory.""" +from pathlib import Path from typing import Any, cast from unittest.mock import patch import pytest +from agent_framework import Message from agent_framework_declarative._feature_usage import FeatureIndex from agent_framework_declarative._workflows._errors import DeclarativeWorkflowError @@ -87,6 +89,157 @@ def test_valid_workflow_marks_declarative_workflow_used(self): mark_feature_used.assert_called_once_with(FeatureIndex.DECLARATIVE_WORKFLOW) +class TestWorkflowFactoryMessageInput: + """Tests for declarative workflows started with a single Message.""" + + async def test_entry_join_executor_initializes_workflow_inputs_message(self): + """Regression test for #7285: Entry JoinExecutor must accept a single Message input.""" + from agent_framework_declarative._workflows._declarative_base import DECLARATIVE_STATE_KEY + + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml(""" +name: entry-message-inputs-test +actions: + - kind: SendActivity + activity: + text: received +""") + + result = await workflow.run(Message(role="user", contents=["25"], message_id="message-25")) + outputs = result.get_outputs() + assert any("received" in str(output) for output in outputs) + + state_data = workflow._runner.state.get(DECLARATIVE_STATE_KEY) + assert isinstance(state_data, dict) + assert state_data["Inputs"]["input"] == "25" + assert state_data["System"]["LastMessage"] == {"Text": "25", "Id": "message-25"} + assert state_data["System"]["LastMessageText"] == "25" + + @pytest.mark.parametrize("stream", [False, True]) + async def test_message_input_resets_prior_run_state(self, stream: bool) -> None: + """A fresh Message must not inherit state from a reused workflow instance.""" + from agent_framework_declarative._workflows._declarative_base import DECLARATIVE_STATE_KEY + + workflow = WorkflowFactory().create_workflow_from_yaml(""" +name: fresh-message-state-test +actions: + - kind: SendActivity + activity: + text: received +""") + await workflow.run(Message(role="user", contents=["first"])) + prior = workflow._runner.state.get(DECLARATIVE_STATE_KEY) + assert isinstance(prior, dict) + prior_conversation_id = prior["System"]["ConversationId"] + # Model values persisted by actions during the preceding run. + for namespace in ("Local", "System", "Outputs", "Agent", "Custom"): + prior[namespace]["tenant_secret"] = "first-conversation-only" + prior["Conversation"]["messages"] = [Message(role="assistant", contents=["private history"])] + prior["Conversation"]["history"] = list(prior["Conversation"]["messages"]) + workflow._runner.state.set(DECLARATIVE_STATE_KEY, prior) + workflow._runner.state.commit() + + message = Message(role="user", contents=["second"], message_id="second-message") + if stream: + events = [event async for event in workflow.run(message, stream=True)] + assert any(event.type == "output" for event in events) + else: + result = await workflow.run(message) + assert result.get_outputs() + + current = workflow._runner.state.get(DECLARATIVE_STATE_KEY) + assert isinstance(current, dict) + for namespace in ("Local", "System", "Outputs", "Agent", "Custom"): + assert "tenant_secret" not in current[namespace], namespace + assert current["Conversation"] == {"messages": [], "history": []} + assert current["System"]["ConversationId"] != prior_conversation_id + assert prior_conversation_id not in current["System"]["conversations"] + assert current["Inputs"] == {"input": "second"} + assert current["System"]["LastMessage"] == {"Text": "second", "Id": "second-message"} + assert current["System"]["LastMessageText"] == "second" + assert current["System"]["LastMessageId"] == "second-message" + + @pytest.mark.parametrize("restore_with_responses", [False, True]) + @_requires_powerfx + async def test_message_input_checkpoint_restores_own_state(self, restore_with_responses: bool) -> None: + """Restoring a paused run must recover its state after another conversation ran.""" + from agent_framework import InMemoryCheckpointStorage + + from agent_framework_declarative._workflows import ExternalInputResponse + from agent_framework_declarative._workflows._declarative_base import DECLARATIVE_STATE_KEY + + workflow = WorkflowFactory().create_workflow_from_yaml(""" +name: message-checkpoint-state-test +actions: + - kind: SetValue + path: Local.tenant_secret + value: =System.LastMessageText + - kind: Question + question: + text: Continue? + variable: Local.answer + - kind: SendActivity + activity: + text: =Local.tenant_secret +""") + first_storage = InMemoryCheckpointStorage() + first = await workflow.run(Message(role="user", contents=["first-secret"]), checkpoint_storage=first_storage) + first_request_id = first.get_request_info_events()[0].request_id + checkpoints = await first_storage.list_checkpoints(workflow_name=workflow.name) + # Wall-clock timestamps can tie on Windows. Select the checkpoint + # that actually contains this run's pending question, not an earlier step. + checkpoint = next(item for item in checkpoints if first_request_id in item.pending_request_info_events) + assert first_request_id in checkpoint.pending_request_info_events + first_state = workflow._runner.state.get(DECLARATIVE_STATE_KEY) + assert isinstance(first_state, dict) + first_conversation_id = first_state["System"]["ConversationId"] + await workflow.run( + responses={first_request_id: ExternalInputResponse(user_input="finish first")}, + checkpoint_storage=first_storage, + ) + + second_storage = InMemoryCheckpointStorage() + second = await workflow.run(Message(role="user", contents=["second-secret"]), checkpoint_storage=second_storage) + second_request_id = second.get_request_info_events()[0].request_id + await workflow.run( + responses={second_request_id: ExternalInputResponse(user_input="finish second")}, + checkpoint_storage=second_storage, + ) + + responses = {first_request_id: ExternalInputResponse(user_input="restored answer")} + if restore_with_responses: + restored = await workflow.run( + checkpoint_id=checkpoint.checkpoint_id, checkpoint_storage=first_storage, responses=responses + ) + else: + await workflow.run(checkpoint_id=checkpoint.checkpoint_id, checkpoint_storage=first_storage) + restored = await workflow.run(responses=responses, checkpoint_storage=first_storage) + + assert restored.get_outputs() == ["first-secret"] + restored_state = workflow._runner.state.get(DECLARATIVE_STATE_KEY) + assert isinstance(restored_state, dict) + assert restored_state["Local"] == {"tenant_secret": "first-secret", "answer": "restored answer"} + assert restored_state["Inputs"] == {"input": "first-secret"} + assert restored_state["System"]["ConversationId"] == first_conversation_id + + @pytest.mark.parametrize( + ("age", "category"), + [(8, "child"), (16, "teenager"), (25, "adult"), (70, "senior")], + ) + @_requires_powerfx + async def test_devui_declarative_workflow_categorizes_message_input(self, age: int, category: str): + """Regression test for #7285: The DevUI sample must categorize chat message input by age.""" + workflow_path = ( + Path(__file__).parents[3] / "samples" / "02-agents" / "devui" / "workflow_declarative" / "workflow.yaml" + ) + workflow = WorkflowFactory().create_workflow_from_yaml_path(workflow_path) + + result = await workflow.run(Message(role="user", contents=[str(age)])) + outputs = result.get_outputs() + + assert any(f"categorized as: {category}" in str(output) for output in outputs) + + @_requires_powerfx class TestWorkflowFactoryExecution: """Tests for workflow execution.""" diff --git a/python/samples/02-agents/devui/workflow_declarative/workflow.yaml b/python/samples/02-agents/devui/workflow_declarative/workflow.yaml index 947f1688389..2ac3d2dcf49 100644 --- a/python/samples/02-agents/devui/workflow_declarative/workflow.yaml +++ b/python/samples/02-agents/devui/workflow_declarative/workflow.yaml @@ -1,52 +1,48 @@ name: conditional-workflow -description: Demonstrates conditional branching based on user input - -inputs: - age: - type: integer - description: The user's age in years +description: Demonstrates conditional branching based on age entered in DevUI chat actions: - - kind: SetValue + - kind: ParseValue id: get_age displayName: Get user age - path: turn.age - value: =inputs.age + variable: Local.age + value: =System.LastMessage.Text + valueType: number - kind: If id: check_age displayName: Check age category - condition: =turn.age < 13 + condition: =Local.age < 13 then: - kind: SetValue - path: turn.category + path: Local.category value: child - kind: SendActivity activity: text: "Welcome, young one! Here are some fun activities for kids." else: - kind: If - condition: =turn.age < 20 + condition: =Local.age < 20 then: - kind: SetValue - path: turn.category + path: Local.category value: teenager - kind: SendActivity activity: text: "Hey there! Check out these cool things for teens." else: - kind: If - condition: =turn.age < 65 + condition: =Local.age < 65 then: - kind: SetValue - path: turn.category + path: Local.category value: adult - kind: SendActivity activity: text: "Welcome! Here are our professional services." else: - kind: SetValue - path: turn.category + path: Local.category value: senior - kind: SendActivity activity: @@ -56,9 +52,9 @@ actions: id: summary displayName: Send category summary activity: - text: '=Concat("You have been categorized as: ", turn.category)' + text: '=Concat("You have been categorized as: ", Local.category)' - kind: SetValue id: set_output - path: workflow.outputs.category - value: =turn.category + path: Workflow.Outputs.category + value: =Local.category From fafc4e8fa4795fafdcc2d5a9ae503c4ceb8bbbf8 Mon Sep 17 00:00:00 2001 From: Manjunath Janardhan <35326118+manjunathshiva@users.noreply.github.com> Date: Tue, 8 Sep 2026 06:18:09 +0000 Subject: [PATCH 71/85] Python: fix: preserve tool call/result order in AG-UI message split (#8005) * Python: fix: preserve tool call/result order in AG-UI message split When a single Agent Framework message interleaved assistant content (function_call / text) with function_result content, the AG-UI converter emitted every tool result first and the assistant call/text segment afterwards. A [function_call, function_result] message therefore round-tripped as [function_result, function_call], reversing the transcript. Providers require a tool result to follow its matching assistant call, so the reversed order produced an orphan result (rejected or dropped) while the call stayed pending. Walk msg.contents in order and flush any accumulated assistant segment before each function_result, so calls always precede their results. Parallel results and trailing summary text are preserved, and the source message id is still kept on the first emitted message with independent generated ids for the rest. Follow-up to #7980 (addresses moonbox3's post-merge review feedback). * Python: defer text-only content before AG-UI tool results The mixed-message split flushed every accumulated segment before each function_result, including text-only segments. A [text, function_result] message whose result answers a call in a prior assistant message therefore emitted an assistant-only text message between the outstanding call and its result. _sanitize_tool_history then treats the call as abandoned, clears it, and drops the real result, leaving the provider with an unanswered tool call. Defer text-only content and emit it after the results; only a segment that carries tool calls is flushed before a result (with any buffered text coalesced), so a call still precedes its matching result and no assistant-only message is ever inserted between an outstanding call and its result. Addresses the MAF automated-review finding on this PR. * Python: fix AG-UI split for interleaved parallel tool-call batches Defer a new assistant segment while earlier emitted calls still await their results, so an interleaved batch like [call A, call B, result A, call C, result B, result C] no longer places assistant(C) between call B and result B (which _sanitize_tool_history would drop as orphaned). Extract a shared _encode_agui_segment() used by both the split and single-message paths so the text/function_call serialization can't drift. * Python: keep AG-UI tool results behind their calls across messages Carry the unresolved-call set across the whole conversion instead of rebuilding it per mixed message, so a call opened by an earlier message stays open and a later mixed message cannot place a new assistant segment between that call and its result. Queue a result whose own call is still buffered and release it only after that call is emitted, so a result never precedes its call. Add an exhaustive regression test over every valid call/result interleaving, in both single-message and split-message shapes, asserting that no result is dropped by _sanitize_tool_history and none is emitted ahead of its call. --- .../_message_adapters.py | 240 ++++++++++--- .../tests/ag_ui/test_message_adapters.py | 340 ++++++++++++++++++ 2 files changed, 530 insertions(+), 50 deletions(-) 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 48e2f45e350..24624517ebc 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 @@ -958,6 +958,162 @@ def _filter_modified_args( return result +def _encode_agui_segment(contents: list[Content]) -> tuple[str, list[dict[str, Any]]]: + """Encode assistant contents into an AG-UI ``(content, tool_calls)`` pair. + + Shared by both the single-message path (``agent_framework_messages_to_agui``) and the + split path (``_split_mixed_message_to_agui``) so the text / function_call + serialization lives in one place. A future argument-format or supported-content + change then updates both paths at once instead of drifting between them. + """ + text = "" + tool_calls: list[dict[str, Any]] = [] + for content in contents: + if content.type == "text": + text += content.text or "" + elif content.type == "function_call": + tool_calls.append( + { + "id": content.call_id, + "type": "function", + "function": { + "name": content.name, + "arguments": content.arguments, + }, + } + ) + return text, tool_calls + + +def _split_mixed_message_to_agui(msg: Message, role: str, unresolved_call_ids: set[str]) -> list[dict[str, Any]]: + """Convert a Message that carries function_result content into ordered AG-UI messages. + + A single Agent Framework message can interleave assistant content (text, + function_call) with one or more function_result (tool) contents -- for example a + parallel tool-call batch or a finalized turn. AG-UI needs each function_result as + its own ``tool`` message. ``_sanitize_tool_history`` resets its pending-call set on + every non-tool message, so a result is dropped as orphaned whenever an assistant + message lands between a call and that call's result. Four ordering rules keep the + transcript provider-valid: + + * A ``function_call`` must precede its matching result, so a pending assistant + segment that carries tool calls is flushed (together with any buffered text) right + before the result. + * A text-only segment is NOT flushed before a result. Such text is deferred and + emitted after the results instead. + * A new assistant segment is NOT flushed while earlier emitted calls are still + awaiting their results. For ``[call A, call B, result A, call C, result B, + result C]``, flushing ``assistant(C)`` before ``result B`` would separate the + still-open call B from its result; deferring it yields ``[assistant(A,B), tool(A), + tool(B), assistant(C), tool(C)]``. + * A result whose own call is still buffered (not yet emitted) is QUEUED rather than + emitted, because emitting it would leave it ahead of its call. For + ``[call A, call B, result A, call C, result C, result B]`` the queued ``result C`` + is held until ``assistant(C)`` is flushed, giving ``[assistant(A,B), tool(A), + tool(B), assistant(C), tool(C)]``. + + ``unresolved_call_ids`` is owned by ``agent_framework_messages_to_agui`` and carried + across the whole conversion, not rebuilt per message: a call emitted by an earlier + message stays open until its result is emitted, so a later mixed message cannot slip + a new assistant segment between that call and its result (e.g. a prior + ``assistant(call A)`` followed by ``[call C, result A, result C]``). + + The source message id is kept on the first emitted message; every additional + message gets an independent generated id. Deriving suffixes from the source id + (e.g. ``f"{base_id}-1"``) risks colliding with a legitimate id elsewhere in the + history, which would let id-keyed clients re-collapse the split messages. + """ + from ._utils import generate_event_id + + messages: list[dict[str, Any]] = [] + seg_contents: list[Content] = [] + seg_has_call = False + seg_call_ids: set[str] = set() + # Results whose own call is still buffered; emitted once that segment is flushed. + queued_results: list[Content] = [] + source_id_available = bool(msg.message_id) + + def next_id() -> str: + nonlocal source_id_available + if source_id_available and msg.message_id: + source_id_available = False + return msg.message_id + source_id_available = False + return generate_event_id() + + def flush_segment() -> None: + nonlocal seg_contents, seg_has_call + if not seg_contents: + return + seg_text, seg_tool_calls = _encode_agui_segment(seg_contents) + seg_contents = [] + seg_has_call = False + seg_call_ids.clear() + if not seg_text and not seg_tool_calls: + return + assistant_msg: dict[str, Any] = {"id": next_id(), "role": role, "content": seg_text} + if seg_tool_calls: + assistant_msg["tool_calls"] = seg_tool_calls + unresolved_call_ids.update(str(tc["id"]) for tc in seg_tool_calls if tc["id"] is not None) + messages.append(assistant_msg) + + def emit_result(content: Content) -> None: + messages.append( + { + "id": next_id(), + "role": "tool", + "content": content.result if content.result is not None else "", + "toolCallId": content.call_id, + } + ) + if content.call_id is not None: + unresolved_call_ids.discard(str(content.call_id)) + + def drain_queued() -> None: + """Flush the buffered segment, then release the results waiting on its calls.""" + if not queued_results: + return + flush_segment() + for queued in queued_results: + emit_result(queued) + queued_results.clear() + + for content in msg.contents: + if content.type in ("text", "function_call"): + seg_contents.append(content) + if content.type == "function_call": + seg_has_call = True + if content.call_id is not None: + seg_call_ids.add(str(content.call_id)) + elif content.type == "function_result": + call_id = str(content.call_id) if content.call_id is not None else None + if call_id is not None and call_id in unresolved_call_ids: + # Its call is already emitted and still open, so the result can go now. + emit_result(content) + if not unresolved_call_ids: + drain_queued() + elif seg_has_call and not unresolved_call_ids: + # No older batch is open: flush the buffered segment so its calls precede + # this result, then emit it. + flush_segment() + emit_result(content) + drain_queued() + elif call_id is not None and call_id in seg_call_ids: + # This result's call is still buffered behind an open older batch. + # Emitting now would put the result ahead of its call, so hold it. + queued_results.append(content) + else: + # Its call came from an already-emitted message: emit in place. + emit_result(content) + + # Emit any deferred / trailing segment (buffered text, or a new-call segment whose + # results arrive in a later message), then release anything still queued behind it. + flush_segment() + for queued in queued_results: + emit_result(queued) + return messages + + def agent_framework_messages_to_agui(messages: list[Message] | list[dict[str, Any]]) -> list[dict[str, Any]]: """Convert Agent Framework messages to AG-UI format. @@ -970,6 +1126,25 @@ def agent_framework_messages_to_agui(messages: list[Message] | list[dict[str, An from ._utils import generate_event_id result: list[dict[str, Any]] = [] + # Calls emitted so far whose results have not been emitted yet. Carried across every + # message (mirroring _sanitize_tool_history's pending set) so a mixed message never + # slips a new assistant segment between an earlier call and its result. + unresolved_call_ids: set[str] = set() + + def track_emitted( + role_value: str | None, tool_calls: list[dict[str, Any]] | None, tool_call_id: Any = None + ) -> None: + """Mirror _sanitize_tool_history's pending-call bookkeeping for an emitted message.""" + if role_value == "tool": + if tool_call_id: + unresolved_call_ids.discard(str(tool_call_id)) + return + # Any non-tool message resets the pending set to the calls it introduces. + unresolved_call_ids.clear() + for tool_call in tool_calls or []: + if isinstance(tool_call, dict) and tool_call.get("id") is not None: + unresolved_call_ids.add(str(tool_call["id"])) + for msg in messages: # If already a dict (AG-UI format), ensure it has an ID and normalize keys for Pydantic if isinstance(msg, dict): @@ -989,64 +1164,28 @@ def agent_framework_messages_to_agui(messages: list[Message] | list[dict[str, An normalized_msg["toolCallId"] = "" # Always append the normalized copy, not the original result.append(normalized_msg) + track_emitted( + normalized_msg.get("role"), + normalized_msg.get("tool_calls"), + normalized_msg.get("toolCallId"), + ) continue # Convert Message to AG-UI format role_value: str = msg.role if hasattr(msg.role, "value") else msg.role role = FRAMEWORK_TO_AGUI_ROLE.get(role_value, "user") - content_text = "" - tool_calls: list[dict[str, Any]] = [] - function_results: list[Any] = [] - - for content in msg.contents: - if content.type == "text": - content_text += content.text # type: ignore[operator] - elif content.type == "function_call": - tool_calls.append( - { - "id": content.call_id, - "type": "function", - "function": { - "name": content.name, - "arguments": content.arguments, - }, - } - ) - elif content.type == "function_result": - function_results.append(content) - - # A single Agent Framework message can carry several function_result - # contents (parallel tool calls). Emit one AG-UI tool message per result so - # none are dropped and each keeps its own toolCallId. - if function_results: - # Preserve the source id for the first result; give every additional - # message an independent generated id. Deriving suffixes from the source - # id (e.g. f"{base_id}-1") risks colliding with a legitimate id elsewhere - # in the history, which would let id-keyed clients re-collapse results. - for idx, fr in enumerate(function_results): - result.append( - { - "id": msg.message_id if (idx == 0 and msg.message_id) else generate_event_id(), - "role": "tool", - "content": fr.result if fr.result is not None else "", - "toolCallId": fr.call_id, - } - ) - # A mixed message may also carry text / function_call contents alongside - # the tool results (e.g. a finalized assistant turn). Emit those as a - # separate, distinctly-identified message so they are not lost. - if content_text or tool_calls: - extra_msg: dict[str, Any] = { - "id": generate_event_id(), - "role": role, - "content": content_text, - } - if tool_calls: - extra_msg["tool_calls"] = tool_calls - result.append(extra_msg) + # A message carrying function_result content may interleave assistant + # (text/function_call) and tool (function_result) segments -- e.g. parallel + # tool calls or a finalized turn. Split it into ordered AG-UI messages so no + # result is dropped and each result stays after its matching call. Messages + # with no result use the simple single-message form below. + if any(content.type == "function_result" for content in msg.contents): + result.extend(_split_mixed_message_to_agui(msg, role, unresolved_call_ids)) continue + content_text, tool_calls = _encode_agui_segment(msg.contents) + agui_msg: dict[str, Any] = { "id": msg.message_id if msg.message_id else generate_event_id(), # Always include id "role": role, @@ -1057,6 +1196,7 @@ def agent_framework_messages_to_agui(messages: list[Message] | list[dict[str, An agui_msg["tool_calls"] = tool_calls result.append(agui_msg) + track_emitted(role, tool_calls) return result 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 1749cafe724..a756009df6a 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 @@ -5,6 +5,7 @@ import base64 import json import logging +from itertools import permutations from typing import Any import pytest @@ -15,6 +16,7 @@ agui_messages_to_agent_framework, agui_messages_to_snapshot_format, extract_text_from_contents, + normalize_agui_input_messages, ) @@ -963,6 +965,344 @@ def test_agent_framework_to_agui_function_result_with_text_preserves_both(): assert text_msg["id"] != tool_msg["id"] +def test_agent_framework_to_agui_function_call_precedes_its_result(): + """A [function_call, function_result] message keeps the call before the result (no orphan).""" + msg = Message( + role="assistant", + contents=[ + Content.from_function_call(call_id="call_a", name="get_weather", arguments={"city": "Seattle"}), + Content.from_function_result(call_id="call_a", result="Sunny"), + ], + message_id="mixed-order-1", + ) + + messages = agent_framework_messages_to_agui([msg]) + + assert len(messages) == 2 + assistant_msg, tool_msg = messages + # The assistant call must be emitted before its result; a result ahead of its + # matching call would be an orphan that providers reject. + assert assistant_msg["role"] == "assistant" + assert [tc["id"] for tc in assistant_msg["tool_calls"]] == ["call_a"] + assert tool_msg["role"] == "tool" + assert tool_msg["toolCallId"] == "call_a" + assert tool_msg["content"] == "Sunny" + # First emitted message keeps the source id; the split-off message gets its own. + assert assistant_msg["id"] == "mixed-order-1" + assert tool_msg["id"] != assistant_msg["id"] + + +def test_agent_framework_to_agui_call_result_text_order_preserved(): + """[function_call, function_result, text] round-trips in order: call, result, then summary text.""" + msg = Message( + role="assistant", + contents=[ + Content.from_function_call(call_id="call_a", name="get_weather", arguments="{}"), + Content.from_function_result(call_id="call_a", result="Sunny"), + Content.from_text("It is sunny."), + ], + message_id="mixed-order-2", + ) + + messages = agent_framework_messages_to_agui([msg]) + + assert [m["role"] for m in messages] == ["assistant", "tool", "assistant"] + assert messages[0]["tool_calls"][0]["id"] == "call_a" + assert messages[1]["toolCallId"] == "call_a" + assert messages[2]["content"] == "It is sunny." + # Only the first emitted message reuses the source id, and all ids are distinct. + assert messages[0]["id"] == "mixed-order-2" + assert len({m["id"] for m in messages}) == 3 + + +def test_agent_framework_to_agui_text_before_result_deferred_after_result(): + """Text preceding a result is emitted AFTER the result, never as an assistant-only message before it.""" + msg = Message( + role="assistant", + contents=[ + Content.from_text("Here is the weather."), + Content.from_function_result(call_id="weather-call", result="Sunny"), + ], + message_id="mixed-text-first", + ) + + messages = agent_framework_messages_to_agui([msg]) + + assert len(messages) == 2 + tool_msg, text_msg = messages + # The tool result comes first; the text-only assistant message follows it, so it can + # never separate a prior outstanding call from this result. + assert tool_msg["role"] == "tool" + assert tool_msg["toolCallId"] == "weather-call" + assert text_msg["role"] == "assistant" + assert "tool_calls" not in text_msg + assert text_msg["content"] == "Here is the weather." + + +def test_agent_framework_to_agui_text_before_result_round_trips_without_dropping(): + """A prior call + a [text, result] message must not drop the result through sanitize_tool_history. + + Regression for the MAF review finding: emitting a text-only assistant message between an + outstanding call and its result made ``_sanitize_tool_history`` clear the pending call and + drop the real result, leaving the provider with an unanswered tool call. + """ + framework_messages = [ + Message( + role="assistant", + contents=[Content.from_function_call(call_id="call_a", name="get_weather", arguments="{}")], + message_id="m1", + ), + Message( + role="assistant", + contents=[ + Content.from_text("Let me check the weather."), + Content.from_function_result(call_id="call_a", result="Sunny"), + ], + message_id="m2", + ), + ] + + agui_messages = agent_framework_messages_to_agui(framework_messages) + + # The call is immediately followed by its result (no assistant message in between). + assert [m["role"] for m in agui_messages] == ["assistant", "tool", "assistant"] + assert agui_messages[0]["tool_calls"][0]["id"] == "call_a" + assert agui_messages[1]["toolCallId"] == "call_a" + + # Round-trip through provider normalization: the real result must survive. + provider_messages, _ = normalize_agui_input_messages(agui_messages, sanitize_tool_history=True) + surviving_result_ids = { + content.call_id + for message in provider_messages + for content in (message.contents or []) + if content.type == "function_result" + } + assert "call_a" in surviving_result_ids + + +def test_agent_framework_to_agui_interleaved_parallel_batch_order_preserved(): + """An interleaved parallel batch keeps every call adjacent to its results. + + A new call (C) appearing before the preceding batch's results are all emitted must not + start a new assistant segment ahead of the still-open results (A, B). The split defers + ``assistant(C)`` until B's result has been emitted. + """ + msg = Message( + role="assistant", + contents=[ + Content.from_function_call(call_id="call_a", name="fa", arguments="{}"), + Content.from_function_call(call_id="call_b", name="fb", arguments="{}"), + Content.from_function_result(call_id="call_a", result="ra"), + Content.from_function_call(call_id="call_c", name="fc", arguments="{}"), + Content.from_function_result(call_id="call_b", result="rb"), + Content.from_function_result(call_id="call_c", result="rc"), + ], + message_id="interleaved-1", + ) + + messages = agent_framework_messages_to_agui([msg]) + + # assistant(A,B) -> tool(A) -> tool(B) -> assistant(C) -> tool(C): the new call C is + # deferred until the open batch {A, B} is fully resolved, so no assistant message ever + # separates B's call from B's result. + assert [m["role"] for m in messages] == ["assistant", "tool", "tool", "assistant", "tool"] + assert [tc["id"] for tc in messages[0]["tool_calls"]] == ["call_a", "call_b"] + assert messages[1]["toolCallId"] == "call_a" + assert messages[2]["toolCallId"] == "call_b" + assert [tc["id"] for tc in messages[3]["tool_calls"]] == ["call_c"] + assert messages[4]["toolCallId"] == "call_c" + # First emitted message keeps the source id; every other id is independent. + assert messages[0]["id"] == "interleaved-1" + assert len({m["id"] for m in messages}) == len(messages) + + +def test_agent_framework_to_agui_interleaved_batch_round_trips_without_dropping(): + """An interleaved parallel batch must not drop any result through sanitize_tool_history. + + Regression for the review finding: with the naive split, ``[call A, call B, result A, + call C, result B, result C]`` became ``[assistant(A,B), tool(A), assistant(C), tool(B), + tool(C)]``; the intervening ``assistant(C)`` cleared the pending call B, so + ``_sanitize_tool_history`` dropped B's real result. + """ + msg = Message( + role="assistant", + contents=[ + Content.from_function_call(call_id="call_a", name="fa", arguments="{}"), + Content.from_function_call(call_id="call_b", name="fb", arguments="{}"), + Content.from_function_result(call_id="call_a", result="ra"), + Content.from_function_call(call_id="call_c", name="fc", arguments="{}"), + Content.from_function_result(call_id="call_b", result="rb"), + Content.from_function_result(call_id="call_c", result="rc"), + ], + message_id="interleaved-2", + ) + + agui_messages = agent_framework_messages_to_agui([msg]) + + # Round-trip through provider normalization: every result must survive. + provider_messages, _ = normalize_agui_input_messages(agui_messages, sanitize_tool_history=True) + surviving_result_ids = { + content.call_id + for message in provider_messages + for content in (message.contents or []) + if content.type == "function_result" + } + assert surviving_result_ids == {"call_a", "call_b", "call_c"} + + +def test_agent_framework_to_agui_pending_call_carries_across_messages(): + """A call opened by an earlier message stays open across the conversion. + + Regression: the unresolved-call set used to be rebuilt per mixed message, so a prior + ``assistant(call A)`` followed by ``[call C, result A, result C]`` emitted + ``assistant(A), assistant(C), tool(A), tool(C)``; the intervening ``assistant(C)`` + cleared pending call A and ``_sanitize_tool_history`` dropped A's real result. + """ + framework_messages = [ + Message( + role="assistant", + contents=[Content.from_function_call(call_id="call_a", name="fa", arguments="{}")], + message_id="m1", + ), + Message( + role="assistant", + contents=[ + Content.from_function_call(call_id="call_c", name="fc", arguments="{}"), + Content.from_function_result(call_id="call_a", result="ra"), + Content.from_function_result(call_id="call_c", result="rc"), + ], + message_id="m2", + ), + ] + + agui_messages = agent_framework_messages_to_agui(framework_messages) + + # assistant(A) -> tool(A) -> assistant(C) -> tool(C): the new call C is deferred until + # the earlier call A has received its result. + assert [m["role"] for m in agui_messages] == ["assistant", "tool", "assistant", "tool"] + assert [tc["id"] for tc in agui_messages[0]["tool_calls"]] == ["call_a"] + assert agui_messages[1]["toolCallId"] == "call_a" + assert [tc["id"] for tc in agui_messages[2]["tool_calls"]] == ["call_c"] + assert agui_messages[3]["toolCallId"] == "call_c" + + provider_messages, _ = normalize_agui_input_messages(agui_messages, sanitize_tool_history=True) + surviving_result_ids = { + content.call_id + for message in provider_messages + for content in (message.contents or []) + if content.type == "function_result" + } + assert surviving_result_ids == {"call_a", "call_c"} + + +def test_agent_framework_to_agui_result_for_buffered_call_waits_for_its_call(): + """A result whose own call is still buffered is held until that call is emitted. + + Regression: for ``[call A, call B, result A, call C, result C, result B]`` the split + correctly deferred ``assistant(C)`` but still emitted ``tool(C)`` immediately, leaving + C's result ahead of its call so ``_sanitize_tool_history`` dropped it. + """ + msg = Message( + role="assistant", + contents=[ + Content.from_function_call(call_id="call_a", name="fa", arguments="{}"), + Content.from_function_call(call_id="call_b", name="fb", arguments="{}"), + Content.from_function_result(call_id="call_a", result="ra"), + Content.from_function_call(call_id="call_c", name="fc", arguments="{}"), + Content.from_function_result(call_id="call_c", result="rc"), + Content.from_function_result(call_id="call_b", result="rb"), + ], + message_id="out-of-order-1", + ) + + agui_messages = agent_framework_messages_to_agui([msg]) + + # Every tool message must come after an assistant message that declared its call. + declared: set[str] = set() + for agui_msg in agui_messages: + if agui_msg["role"] == "assistant": + declared.update(tc["id"] for tc in agui_msg.get("tool_calls") or []) + else: + assert agui_msg["toolCallId"] in declared, f"{agui_msg['toolCallId']} emitted before its call" + + provider_messages, _ = normalize_agui_input_messages(agui_messages, sanitize_tool_history=True) + surviving_result_ids = { + content.call_id + for message in provider_messages + for content in (message.contents or []) + if content.type == "function_result" + } + assert surviving_result_ids == {"call_a", "call_b", "call_c"} + + +def _call_result_interleavings(call_ids: list[str]) -> list[tuple[tuple[str, str], ...]]: + """Every ordering of the given calls and their results where each call precedes its result.""" + events = [("call", call_id) for call_id in call_ids] + [("result", call_id) for call_id in call_ids] + orderings: list[tuple[tuple[str, str], ...]] = [] + for ordering in dict.fromkeys(permutations(events)): + emitted: set[str] = set() + for kind, call_id in ordering: + if kind == "call": + emitted.add(call_id) + elif call_id not in emitted: + break + else: + orderings.append(ordering) + return orderings + + +def _contents_for(ordering: tuple[tuple[str, str], ...]) -> list[Content]: + return [ + Content.from_function_call(call_id=call_id, name="f", arguments="{}") + if kind == "call" + else Content.from_function_result(call_id=call_id, result=f"r-{call_id}") + for kind, call_id in ordering + ] + + +def _assert_no_result_is_orphaned(agui_messages: list[dict[str, Any]], call_ids: list[str]) -> None: + """Every tool message follows an assistant message declaring its call, and no result is lost.""" + declared: set[str] = set() + for agui_msg in agui_messages: + if agui_msg["role"] == "assistant": + declared.update(tool_call["id"] for tool_call in agui_msg.get("tool_calls") or []) + elif agui_msg["role"] == "tool": + assert agui_msg["toolCallId"] in declared, f"{agui_msg['toolCallId']} emitted before its call" + + provider_messages, _ = normalize_agui_input_messages(agui_messages, sanitize_tool_history=True) + surviving = { + content.call_id + for message in provider_messages + for content in (message.contents or []) + if content.type == "function_result" + } + assert surviving == set(call_ids) + + +@pytest.mark.parametrize("call_ids", [["call_a", "call_b"], ["call_a", "call_b", "call_c"]]) +def test_agent_framework_to_agui_no_interleaving_drops_a_result(call_ids: list[str]): + """Exhaustive: no ordering of calls/results may lose a result or emit one ahead of its call. + + ``_sanitize_tool_history`` resets its pending-call set on every non-tool message, so any + assistant message emitted between a call and that call's result causes the result to be + dropped. This sweeps every valid interleaving -- in a single mixed message and split across + two messages -- because that whole class of ordering bug has recurred here repeatedly. + """ + for ordering in _call_result_interleavings(call_ids): + contents = _contents_for(ordering) + + single = [Message(role="assistant", contents=contents, message_id="m1")] + _assert_no_result_is_orphaned(agent_framework_messages_to_agui(single), call_ids) + + # Splitting the leading content into its own message exercises the cross-message + # pending-call state that a per-message set would lose. + split = [ + Message(role="assistant", contents=contents[:1], message_id="m1"), + Message(role="assistant", contents=contents[1:], message_id="m2"), + ] + _assert_no_result_is_orphaned(agent_framework_messages_to_agui(split), call_ids) + + # Additional tests for better coverage From 8fcb052bffb2b61836ed1b0aa15fd83eb3e97311 Mon Sep 17 00:00:00 2001 From: Sriraj <168966222+sricursion@users.noreply.github.com> Date: Tue, 8 Sep 2026 06:23:46 +0000 Subject: [PATCH 72/85] [BREAKING] Python: restrict checkpoint deserialization in FoundryCheckpointStore (#8045) * Python: restrict checkpoint deserialization in FoundryCheckpointStore `FileCheckpointStorage` and the Cosmos checkpoint storage both take an `allowed_checkpoint_types` argument, hold it as `self._allowed_types`, and pass it to `decode_checkpoint_value`. `FoundryCheckpointStore` had neither, and called the decoder with the argument omitted in `load` and again in `list_checkpoints`. An omitted `allowed_types` means no restriction and falls through to plain `pickle.loads`, while the empty frozenset the other two stores pass by default selects the restricted unpickler. So the Foundry store was the only one of the three not following the module's own guidance that the argument be specified whenever possible. Give it the same argument and pass it on, so a workflow that restores on one store restores on the others. This is a behaviour change for applications on this store whose checkpoints hold their own types and which never registered them, since those are relying on the store being more permissive than the other two. They register the types with `register_checkpoint_type` or pass `allowed_checkpoint_types`, which an application on the file or Cosmos store already has to do. * Forward allowed_checkpoint_types through CheckpointStoreProvider `ResponsesHostServer` builds a `CheckpointStoreProvider` itself on the default path, so an option settable only on `FoundryCheckpointStore` was out of reach for a hosted app: it would have had to register types process-wide or replace the whole provider. Take the list on the provider and pass it to each store it creates. The default is unchanged, so a provider built with no arguments still restricts exactly as before. --- .../_state_store.py | 37 ++++- .../foundry_hosting/tests/test_state_store.py | 126 ++++++++++++++++++ 2 files changed, 159 insertions(+), 4 deletions(-) diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_state_store.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_state_store.py index d73b582f9be..a88c984ead5 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_state_store.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_state_store.py @@ -72,19 +72,30 @@ class FoundryCheckpointStore: DEFAULT_ROOT_SCOPE = "checkpoints" - def __init__(self, context_id: str, platform_context: FoundryAgentRequestContext) -> None: + def __init__( + self, + context_id: str, + platform_context: FoundryAgentRequestContext, + *, + allowed_checkpoint_types: list[str] | None = None, + ) -> None: """Initialize a Foundry-scoped checkpoint store for the given context ID. Args: context_id: A string that uniquely identifies the context for which the checkpoint store is scoped. This can be used to isolate checkpoints for different workflow runs. platform_context: The request-scoped platform context for the current request. + allowed_checkpoint_types: Additional types (beyond the built-in safe set + and framework types) that are permitted during checkpoint + deserialization. Each entry should be a ``"module:qualname"`` + string (e.g., ``"my_app.models:MyState"``). """ if not context_id: raise ValueError("context_id must be provided to initialize a FoundryCheckpointStore.") self.context_id = context_id self.platform_context = platform_context + self._allowed_types: frozenset[str] = frozenset(allowed_checkpoint_types or []) async def _get_store(self) -> FoundryStateStore: return await FoundryStateStore.get_or_create( @@ -131,7 +142,7 @@ async def load(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint: item = await store.get_item(checkpoint_id, call_id=self.platform_context.call_id) if item is None: raise WorkflowCheckpointException(f"No checkpoint found with ID {checkpoint_id}") - return WorkflowCheckpoint.from_dict(decode_checkpoint_value(item.value)) + return WorkflowCheckpoint.from_dict(decode_checkpoint_value(item.value, allowed_types=self._allowed_types)) async def list_checkpoints(self, *, workflow_name: str) -> list[WorkflowCheckpoint]: """List all workflow checkpoints for a given workflow name.""" @@ -147,7 +158,9 @@ async def list_checkpoints(self, *, workflow_name: str) -> list[WorkflowCheckpoi item = await store.get_item(item_key.key, call_id=self.platform_context.call_id) if item is None: continue - checkpoint = WorkflowCheckpoint.from_dict(decode_checkpoint_value(item.value)) + checkpoint = WorkflowCheckpoint.from_dict( + decode_checkpoint_value(item.value, allowed_types=self._allowed_types) + ) if checkpoint.workflow_name == workflow_name: checkpoints.append(checkpoint) if not page.has_more or page.last_id is None: @@ -182,6 +195,18 @@ class CheckpointStoreProvider(ContextScopedStoreProvider[CheckpointStorage]): This defaults to using the `FoundryCheckpointStore` in all environments. """ + def __init__(self, *, allowed_checkpoint_types: list[str] | None = None) -> None: + """Initialize the provider. + + Args: + allowed_checkpoint_types: Additional types (beyond the built-in safe set + and framework types) that are permitted during checkpoint + deserialization, forwarded to every store this provider creates. + Each entry should be a ``"module:qualname"`` string + (e.g., ``"my_app.models:MyState"``). + """ + self._allowed_checkpoint_types = allowed_checkpoint_types + def get_store( self, *, @@ -193,7 +218,11 @@ def get_store( if not context_id: raise ValueError("context_id must be provided to get a checkpoint store.") - return FoundryCheckpointStore(context_id, platform_context) + return FoundryCheckpointStore( + context_id, + platform_context, + allowed_checkpoint_types=self._allowed_checkpoint_types, + ) # endregion Checkpoint persistence diff --git a/python/packages/foundry_hosting/tests/test_state_store.py b/python/packages/foundry_hosting/tests/test_state_store.py index 23a488c03da..fc36d00373c 100644 --- a/python/packages/foundry_hosting/tests/test_state_store.py +++ b/python/packages/foundry_hosting/tests/test_state_store.py @@ -1,11 +1,13 @@ # Copyright (c) Microsoft. All rights reserved. from collections.abc import Callable +from dataclasses import dataclass from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest from agent_framework import AgentSession, Content, WorkflowCheckpoint, WorkflowCheckpointException +from agent_framework._workflows._checkpoint_encoding import encode_checkpoint_value from azure.ai.agentserver.core import AgentConfig, FoundryAgentRequestContext from azure.ai.agentserver.core.storage import FoundryStorageConflictError @@ -20,6 +22,13 @@ ) +@dataclass +class _NotAllowed: + """A type outside the built-in safe set, standing in for an application type.""" + + value: int + + def _checkpoint( checkpoint_id: str, *, workflow_name: str = "workflow", timestamp: str = "2026-01-01T00:00:00+00:00" ) -> WorkflowCheckpoint: @@ -100,6 +109,123 @@ async def test_load_returns_checkpoint() -> None: store.get_item.assert_awaited_once_with("checkpoint-1", call_id="call-1") +async def test_load_restricts_checkpoint_deserialization() -> None: + """A checkpoint value naming a type outside the allow set is refused. + + The file and Cosmos checkpoint stores both restrict deserialization this + way; this store reaches the same decoder, so it restricts it too. + """ + store = _store() + checkpoint = _checkpoint("checkpoint-1") + value = checkpoint.to_dict() + value["state"] = encode_checkpoint_value({"payload": _NotAllowed(7)}) + store.get_item = AsyncMock(return_value=SimpleNamespace(value=value)) + + with ( + patch( + "agent_framework_foundry_hosting._state_store.FoundryStateStore.get_or_create", + new=AsyncMock(return_value=store), + ), + pytest.raises(WorkflowCheckpointException), + ): + await FoundryCheckpointStore("context-1", _platform_context()).load("checkpoint-1") + + +async def test_load_accepts_a_declared_checkpoint_type() -> None: + """A caller can still name the types its checkpoints carry.""" + store = _store() + checkpoint = _checkpoint("checkpoint-1") + value = checkpoint.to_dict() + value["state"] = encode_checkpoint_value({"payload": _NotAllowed(7)}) + store.get_item = AsyncMock(return_value=SimpleNamespace(value=value)) + + with patch( + "agent_framework_foundry_hosting._state_store.FoundryStateStore.get_or_create", + new=AsyncMock(return_value=store), + ): + result = await FoundryCheckpointStore( + "context-1", + _platform_context(), + allowed_checkpoint_types=[f"{_NotAllowed.__module__}:{_NotAllowed.__qualname__}"], + ).load("checkpoint-1") + + assert result.state["payload"].value == 7 + + +async def test_list_checkpoints_restricts_checkpoint_deserialization() -> None: + store = _store() + checkpoint = _checkpoint("checkpoint-1") + value = checkpoint.to_dict() + value["state"] = encode_checkpoint_value({"payload": _NotAllowed(7)}) + store.list_keys = AsyncMock( + return_value=SimpleNamespace(keys=[SimpleNamespace(key="checkpoint-1")], has_more=False, last_id=None) + ) + store.get_item = AsyncMock(return_value=SimpleNamespace(value=value)) + + with ( + patch( + "agent_framework_foundry_hosting._state_store.FoundryStateStore.get_or_create", + new=AsyncMock(return_value=store), + ), + pytest.raises(WorkflowCheckpointException), + ): + await FoundryCheckpointStore("context-1", _platform_context()).list_checkpoints(workflow_name="workflow") + + +async def test_provider_forwards_allowed_checkpoint_types() -> None: + """A hosted app reaches the option through the provider it actually gets. + + `ResponsesHostServer` builds a `CheckpointStoreProvider` itself on the default + path, so an option only settable on the store would be out of reach there. + """ + store = _store() + checkpoint = _checkpoint("checkpoint-1") + value = checkpoint.to_dict() + value["state"] = encode_checkpoint_value({"payload": _NotAllowed(7)}) + store.get_item = AsyncMock(return_value=SimpleNamespace(value=value)) + + provider = CheckpointStoreProvider( + allowed_checkpoint_types=[f"{_NotAllowed.__module__}:{_NotAllowed.__qualname__}"] + ) + storage = provider.get_store( + config=MagicMock(), + context_id="context-1", + platform_context=_platform_context(), + ) + + with patch( + "agent_framework_foundry_hosting._state_store.FoundryStateStore.get_or_create", + new=AsyncMock(return_value=store), + ): + result = await storage.load("checkpoint-1") + + assert result.state["payload"].value == 7 + + +async def test_provider_restricts_by_default() -> None: + """Without the option the provider's stores restrict, as before.""" + store = _store() + checkpoint = _checkpoint("checkpoint-1") + value = checkpoint.to_dict() + value["state"] = encode_checkpoint_value({"payload": _NotAllowed(7)}) + store.get_item = AsyncMock(return_value=SimpleNamespace(value=value)) + + storage = CheckpointStoreProvider().get_store( + config=MagicMock(), + context_id="context-1", + platform_context=_platform_context(), + ) + + with ( + patch( + "agent_framework_foundry_hosting._state_store.FoundryStateStore.get_or_create", + new=AsyncMock(return_value=store), + ), + pytest.raises(WorkflowCheckpointException), + ): + await storage.load("checkpoint-1") + + async def test_load_raises_for_missing_checkpoint() -> None: store = _store() store.get_item = AsyncMock(return_value=None) From a4e583c909b6cfd371e8cded55c4970243310bc6 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Tue, 8 Sep 2026 06:32:23 +0000 Subject: [PATCH 73/85] Python: Clean up MCP HTTP resources after failed connections (#8126) * Python: Clean up MCP HTTP resources after failed connections Register header-hook and owned HTTP client cleanup on the MCP session exit stack so failed initialization, cancellation, reconnects, and shutdown release resources without closing caller-owned clients. Preserve the request-scoping and redirect behavior from #8039 and cover shared-client declarative cache reuse and eviction. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: Reset MCP setup after failed discovery Close the lifecycle-owned session when post-transport setup fails, reset discovery flags, and roll back partially loaded functions and metadata before retrying. Preserve caller-owned HTTP clients and the original setup exception. Correct the Python 3.10 cancellation expectation, explicitly type async fixture values for Zuban, and assert the joined task result instead of ignoring it. Add discovery failure, cancellation, context-entry, and paginated retry coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: Handle cancelled MCP connect callers and borrowed sessions Require caller acknowledgement before retaining a newly established MCP connection. Skip cancelled queued connects, clean up abandoned setup on the lifecycle owner before subsequent requests, and release idle owners without disrupting established sessions or close operations. Track session ownership so failed discovery, close, and reset preserve constructor-supplied sessions instead of opening the configured transport on retry. Cover true caller cancellation during setup/result delivery and real borrowed-session retries. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- python/packages/core/AGENTS.md | 3 +- python/packages/core/agent_framework/_mcp.py | 147 +++- .../core/tests/core/test_mcp_http_auth.py | 734 ++++++++++++++++++ .../tests/test_default_mcp_tool_handler.py | 82 +- 4 files changed, 927 insertions(+), 39 deletions(-) create mode 100644 python/packages/core/tests/core/test_mcp_http_auth.py diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index 8b64ab24ed3..c6a4affe0eb 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -121,7 +121,8 @@ The vector store API is experimental under the shared `VECTOR_STORES` feature ID - **`allowed_tools`** (constructor arg on all `MCPTool` subclasses) - Restricts exposed MCP tools by raw remote MCP tool identity. Prefixed local names remain accepted only when the raw remote name already matches its normalized form; normalized/local aliases do not authorize a different raw remote name. If multiple raw remote tool names map to the same local function name, tool loading raises `ToolExecutionException` instead of first-one-wins shadowing. - **Progressive MCP disclosure** (`use_progressive_disclosure`, `always_load`) - When enabled on any `MCPTool` subclass, the initial model-facing surface is loader tools (`list_mcp_tools` / `load_tool` / `unload_tool`, prefixed by `tool_name_prefix` when configured) plus allowed tools selected by `always_load` and tools loaded earlier on the same `MCPTool` instance. `list_mcp_tools` only reports tools that pass `allowed_tools`; filtered tools are not listed or loadable. Loader tool names are reserved in progressive mode: remote MCP tools whose local generated name collides with a loader name are omitted from the initial/listed surface, and explicit `load_tool` calls return a model-visible message pointing callers to `tool_name_prefix` or excluding the colliding tool. `load_tool` accepts one tool name or a list of tool names and uses `FunctionInvocationContext.add_tools(...)` so the selected generated MCP `FunctionTool`s become available on the next function-calling iteration while keeping existing approval mode, argument filtering, header-provider runtime kwargs, result parsing, OTel, and task behavior. `unload_tool` accepts one dynamically loaded tool name or a list of names and removes them from the live tool list and persisted progressive surface, but it does not remove tools configured in `always_load`. Invalid `always_load` entries are ignored like unmatched `allowed_tools` entries. - **`additional_tool_argument_names`** (constructor arg on all `MCPTool` subclasses) - Opt extra argument names back into the allowlist. Accepts a `Sequence[str]` (applied to every tool) or a `Mapping[str, Sequence[str]]` keyed by **remote tool name**, where the reserved key `"*"` denotes global extras. It is configured only in user code at construction; there is **no per-call/runtime override**, so a model-issued tool call cannot change which names pass through — but note this constrains the *model*, not the *server*, which still widens the effective allowlist through its schema. To use a server that accepts `additionalProperties: true`, list the extra names here and then either (1) manually extend that tool's `inputSchema` (via the `.functions` list after connecting) so the model is prompted to supply them, or (2) supply the values yourself via `function_invocation_kwargs`. If a normal forwarded argument name is supplied by both the model and `function_invocation_kwargs`, the model-supplied value wins; `_meta` is the exception and only trusted runtime/caller metadata is used. -- **`header_provider` request scoping** - When sharing an `http_client`, keep provider processing scoped to the originating `MCPStreamableHTTPTool`, remove its request hook on `close()`, and strip injected headers from cross-origin redirects. +- **`header_provider` request scoping** - When sharing an `http_client`, keep provider processing scoped to the originating `MCPStreamableHTTPTool` and strip injected headers from cross-origin redirects. The session exit stack removes its request hook after transport shutdown, including failed initialization or discovery, and closes framework-created HTTP clients. Failed discovery resets the connection and discovery flags and rolls back partial function/metadata additions. Framework-created sessions are discarded; constructor-supplied sessions remain caller-owned and reusable across cleanup, close, and reset. +- **MCP lifecycle caller cancellation** - The lifecycle owner skips cancelled queued connect requests and waits for the caller to acknowledge successful setup. If the caller cancels before accepting a newly established connection, the owner tears it down before processing the next request. Cancelling a close waiter does not interrupt teardown, and cancelling a redundant connect does not discard a previously established session. - **`function_invocation_kwargs` and MCP servers** - That dict is shared across every tool in the run, including every attached `MCPTool`, and any name in it reaches a server that declares a matching `inputSchema` property. `header_provider` does not mitigate this — it reads the kwargs without consuming them. To keep a credential out of tool arguments, source it outside `function_invocation_kwargs`: read a `ContextVar` inside the provider (this still allows a different value per request), configure a custom `http_client`, or use `env` for `MCPStdioTool`. - **Sampling guardrails** (`sampling_callback`) - Passing `client=` advertises `SamplingCapability` so the server can send `sampling/createMessage`. Because remote servers are untrusted (confused-deputy risk), the default `sampling_callback` is **deny-by-default** and applies, in order: a per-session rate limit (`sampling_max_requests`, default `_DEFAULT_SAMPLING_MAX_REQUESTS`), an approval gate (`sampling_approval_callback`), and a `maxTokens` cap (`sampling_max_tokens`, default `_DEFAULT_SAMPLING_MAX_TOKENS`). The approval callback (constructor arg on all subclasses; exported type alias `SamplingApprovalCallback`) receives the raw `CreateMessageRequestParams`, may be sync or async, and must return truthy to approve. When it is `None` (the default) every sampling request is denied; pass `lambda params: True` to restore legacy auto-approve as an explicit opt-in. Requests and denials are logged at WARNING (content is not logged). The per-session counter resets in `_reset_session_state`. - **`MCPTaskOptions`** (experimental, `MCP_LONG_RUNNING_TASKS` feature, **frozen**) - Per-tool-instance options controlling the SEP-2663 long-running task lifecycle. When the server advertises a tool with `execution.taskSupport == "required"`, `MCPTool.call_tool` transparently routes through `call_tool_as_task`, which sends an augmented `tools/call`, polls `tasks/get` until terminal, and reinterprets `tasks/result` as a normal `CallToolResult`. Instances are immutable; replace via `MCPTool.task_options = MCPTaskOptions(...)`. Fields: diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index a1604eab471..f1bf4ea4bc0 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -576,7 +576,8 @@ def __init__( MCP prompt results to a string. If you need per-function result parsing, access the ``.functions`` list after connecting and set ``result_parser`` on individual ``FunctionTool`` instances. - session: An existing MCP client session to use. + session: An existing MCP client session to use. The caller retains ownership; + closing or resetting this wrapper does not close or replace that session. request_timeout: Timeout in seconds for MCP requests. client: A chat client for sampling callbacks. sampling_approval_callback: Optional gate invoked before each server-initiated @@ -634,9 +635,12 @@ def __init__( self._lifecycle_lock = asyncio.Lock() self._lifecycle_request_lock = asyncio.Lock() self._function_load_lock = asyncio.Lock() - self._lifecycle_queue: asyncio.Queue[tuple[str, bool, bool, asyncio.Future[None]]] | None = None + self._lifecycle_queue: ( + asyncio.Queue[tuple[str, bool, bool, asyncio.Future[None], asyncio.Future[bool]]] | None + ) = None self._lifecycle_owner_task: asyncio.Task[None] | None = None self.session = session + self._owns_session = session is None self.request_timeout = request_timeout self.client = client self.sampling_approval_callback = sampling_approval_callback @@ -1248,11 +1252,30 @@ async def _run_lifecycle_owner(self) -> None: stop_error: BaseException | None = None try: while True: - action, reset, load_configured, future = await queue.get() + action, reset, load_configured, future, acknowledged = await queue.get() + if action == "connect" and future.cancelled(): + if not self.is_connected and queue.empty(): + return + continue try: if action == "connect": + previous_session = self.session + previously_connected = self.is_connected await self._connect_on_owner(reset=reset, load_configured=load_configured) + new_connection = not previously_connected or self.session is not previous_session + accepted = False + try: + if not future.done(): + future.set_result(None) + # A completed result future cannot tell us that its waiter was + # cancelled before consuming it. Await explicit caller acceptance. + accepted = await acknowledged + finally: + if not accepted and new_connection: + await self._close_on_owner() + if not accepted and new_connection and queue.empty(): + return elif action == "close": await self._close_on_owner() else: @@ -1265,6 +1288,10 @@ async def _run_lifecycle_owner(self) -> None: except Exception as ex: if not future.done(): future.set_exception(ex) + else: + logger.warning( + "MCP lifecycle action %s failed after its caller stopped waiting.", action, exc_info=ex + ) else: if not future.done(): future.set_result(None) @@ -1277,7 +1304,7 @@ async def _run_lifecycle_owner(self) -> None: finally: while True: try: - _, _, _, future = queue.get_nowait() + _, _, _, future, _ = queue.get_nowait() except asyncio.QueueEmpty: break if not future.done(): @@ -1313,8 +1340,15 @@ async def _run_on_lifecycle_owner( raise RuntimeError("MCP lifecycle owner is not available.") future = asyncio.get_running_loop().create_future() - await queue.put((action, reset, load_configured, future)) - await future + acknowledged: asyncio.Future[bool] = asyncio.get_running_loop().create_future() + await queue.put((action, reset, load_configured, future, acknowledged)) + accepted = False + try: + await future + accepted = True + finally: + if not acknowledged.done(): + acknowledged.set_result(accepted) async def _safe_close_exit_stack(self) -> BaseException | None: """Safely close the exit stack, handling unexpected cleanup failures. @@ -1363,6 +1397,8 @@ async def _close_and_check_cancelled(self, ex: BaseException) -> tuple[bool, Bas def _reset_session_state(self) -> None: self._server_capabilities = None + self._tools_loaded = False + self._prompts_loaded = False self._supports_tools = True self._supports_prompts = True self._supports_logging = None @@ -1412,7 +1448,8 @@ async def _connect_on_owner(self, *, reset: bool = False, load_configured: bool """ if reset: await self._safe_close_exit_stack() - self.session = None + if self._owns_session: + self.session = None self.is_connected = False self._reset_session_state() self._exit_stack = AsyncExitStack() @@ -1499,33 +1536,53 @@ async def _connect_on_owner(self, *, reset: bool = False, load_configured: bool logger.debug(error_msg, exc_info=True) raise ToolException(error_msg, inner_exception=ex if isinstance(ex, Exception) else None) from ex self.session = session - elif self.session._request_id == 0: # type: ignore[attr-defined] - # If the session is not initialized, we need to reinitialize it - with create_mcp_client_span("initialize", attributes=self._mcp_base_span_attributes()) as init_span: - initialize_result = await self.session.initialize() - init_span.set_attribute(OtelAttr.MCP_PROTOCOL_VERSION, initialize_result.protocolVersion) - self._set_server_capabilities(getattr(initialize_result, "capabilities", None)) - elif self._server_capabilities is None: - self._set_server_capabilities(getattr(self.session, "_server_capabilities", None)) - logger.debug("Connected to MCP server: %s", self.session) - self.is_connected = True - if load_configured and self.load_tools_flag: - if self._supports_tools: - await self.load_tools() - self._tools_loaded = True - if load_configured and self.load_prompts_flag: - if self._supports_prompts: - await self.load_prompts() - self._prompts_loaded = True - - if logger.level != logging.NOTSET and self._supports_logging is not False: + self._owns_session = True + else: try: - level_name = cast( - Any, next(level for level, value in LOG_LEVEL_MAPPING.items() if value == logger.level) - ) - await self.session.set_logging_level(level_name) - except Exception as exc: - logger.warning("Failed to set log level to %s", logger.level, exc_info=exc) + if self.session._request_id == 0: # type: ignore[attr-defined] + # If the session is not initialized, we need to reinitialize it + with create_mcp_client_span("initialize", attributes=self._mcp_base_span_attributes()) as init_span: + initialize_result = await self.session.initialize() + init_span.set_attribute(OtelAttr.MCP_PROTOCOL_VERSION, initialize_result.protocolVersion) + self._set_server_capabilities(getattr(initialize_result, "capabilities", None)) + elif self._server_capabilities is None: + self._set_server_capabilities(getattr(self.session, "_server_capabilities", None)) + except (Exception, asyncio.CancelledError): + await self._close_on_owner() + raise + functions_before_discovery = self._functions.copy() + call_meta_before_discovery = self._tool_call_meta_by_name + task_support_before_discovery = self._tool_task_support_by_name + param_names_before_discovery = self._tool_param_names_by_name + try: + logger.debug("Connected to MCP server: %s", self.session) + self.is_connected = True + if load_configured and self.load_tools_flag: + if self._supports_tools: + await self.load_tools() + self._tools_loaded = True + if load_configured and self.load_prompts_flag: + if self._supports_prompts: + await self.load_prompts() + self._prompts_loaded = True + + if logger.level != logging.NOTSET and self._supports_logging is not False: + try: + level_name = cast( + Any, next(level for level, value in LOG_LEVEL_MAPPING.items() if value == logger.level) + ) + await self.session.set_logging_level(level_name) + except Exception as exc: + logger.warning("Failed to set log level to %s", logger.level, exc_info=exc) + except (Exception, asyncio.CancelledError): + try: + await self._close_on_owner() + finally: + self._functions[:] = functions_before_discovery + self._tool_call_meta_by_name = call_meta_before_discovery + self._tool_task_support_by_name = task_support_before_discovery + self._tool_param_names_by_name = param_names_before_discovery + raise async def _sampling_request_approved(self, params: types.CreateMessageRequestParams) -> bool: """Run the configured sampling approval gate. @@ -2052,7 +2109,8 @@ async def _close_on_owner(self) -> None: await self._safe_close_exit_stack() self._exit_stack = AsyncExitStack() - self.session = None + if self._owns_session: + self.session = None self.is_connected = False self._reset_session_state() @@ -2903,7 +2961,8 @@ def __init__( access the ``.functions`` list after connecting and set ``result_parser`` on individual ``FunctionTool`` instances. request_timeout: The default timeout in seconds for all requests. - session: The session to use for the MCP connection. + session: An existing MCP client session to use. The caller retains ownership; + closing or resetting this wrapper does not close or replace that session. description: The description of the tool. approval_mode: The approval mode for the tool. This can be: - "always_require": The tool always requires approval before use. @@ -3101,7 +3160,8 @@ def __init__( access the ``.functions`` list after connecting and set ``result_parser`` on individual ``FunctionTool`` instances. request_timeout: The default timeout in seconds for all requests. - session: The session to use for the MCP connection. + session: An existing MCP client session to use. The caller retains ownership; + closing or resetting this wrapper does not close or replace that session. description: The description of the tool. approval_mode: The approval mode for the tool. This can be: - "always_require": The tool always requires approval before use. @@ -3265,6 +3325,7 @@ def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: timeout=Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT), ) self._httpx_client = http_client + self._exit_stack.push_async_callback(self._close_owned_http_client, http_client) if not hasattr(self, "_inject_headers_hook"): @@ -3321,6 +3382,9 @@ async def _inject_headers(request: Request) -> None: # ruff:ignore[unused-async self._header_hook_client = http_client if self._inject_headers_hook not in http_client.event_hooks["request"]: http_client.event_hooks["request"].append(self._inject_headers_hook) + # Register before transport entry so failed connections clean up too, + # while successful sessions keep the hook through transport shutdown. + self._exit_stack.callback(self._remove_header_hook) transport_http_client = ( _MCPHeaderScopedClient(http_client, self._header_request_owner) if http_client is not None else None @@ -3332,6 +3396,14 @@ async def _inject_headers(request: Request) -> None: # ruff:ignore[unused-async terminate_on_close=self.terminate_on_close if self.terminate_on_close is not None else True, ) + async def _close_owned_http_client(self, http_client: AsyncClient) -> None: + """Release a framework-created client without retaining it for reconnect.""" + try: + await http_client.aclose() + finally: + if self._httpx_client is http_client: + self._httpx_client = None + def _remove_header_hook(self) -> None: """Detach this tool's request hook from its HTTP client.""" if self._header_hook_client is None or not hasattr(self, "_inject_headers_hook"): @@ -3459,7 +3531,8 @@ def __init__( access the ``.functions`` list after connecting and set ``result_parser`` on individual ``FunctionTool`` instances. request_timeout: The default timeout in seconds for all requests. - session: The session to use for the MCP connection. + session: An existing MCP client session to use. The caller retains ownership; + closing or resetting this wrapper does not close or replace that session. description: The description of the tool. approval_mode: The approval mode for the tool. This can be: - "always_require": The tool always requires approval before use. diff --git a/python/packages/core/tests/core/test_mcp_http_auth.py b/python/packages/core/tests/core/test_mcp_http_auth.py new file mode 100644 index 00000000000..04c27676060 --- /dev/null +++ b/python/packages/core/tests/core/test_mcp_http_auth.py @@ -0,0 +1,734 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import asyncio +import contextlib +import json +import sys +from collections.abc import AsyncGenerator, AsyncIterator +from typing import Any, Literal, TypeAlias +from unittest.mock import AsyncMock, Mock, patch + +import httpx +import pytest + +from agent_framework import MCPStreamableHTTPTool +from agent_framework.exceptions import ToolException, ToolExecutionException + +MCPHTTPServer: TypeAlias = tuple[httpx.AsyncClient, list[httpx.Request], dict[str, list[str]]] + + +@pytest.fixture +async def mcp_http_server() -> AsyncIterator[MCPHTTPServer]: + requests: list[httpx.Request] = [] + writes: dict[str, list[str]] = {"token-a": [], "token-b": [], "token-c": []} + + async def record_request(request: httpx.Request) -> None: + requests.append(request) + + async def handle(request: httpx.Request) -> httpx.Response: + if request.url.path == "/unrelated": + return httpx.Response(200) + principal = request.headers.get("Authorization", "") + if principal not in writes: + return httpx.Response(401) + if request.method == "GET": + return httpx.Response(405) + if request.method == "DELETE": + return httpx.Response(200) + body = json.loads(request.content) + method = body.get("method") + headers: dict[str, str] = {} + result: dict[str, Any] = {} + if method == "initialize": + headers["mcp-session-id"] = f"session-{principal}" + result = { + "protocolVersion": body["params"]["protocolVersion"], + "capabilities": {"tools": {}, "prompts": {}}, + "serverInfo": {"name": "auth-test", "version": "1"}, + } + elif method == "tools/list": + result = { + "tools": [ + { + "name": "record", + "inputSchema": {"type": "object", "properties": {"marker": {"type": "string"}}}, + } + ] + } + elif method == "tools/call": + await asyncio.sleep(0) + marker = body["params"].get("arguments", {}).get("marker") + if marker is not None: + writes[principal].append(marker) + result = {"content": [{"type": "text", "text": principal}]} + elif method == "prompts/list": + result = {"prompts": []} + if "id" not in body: + return httpx.Response(202) + return httpx.Response(200, headers=headers, json={"jsonrpc": "2.0", "id": body["id"], "result": result}) + + async with httpx.AsyncClient( + transport=httpx.MockTransport(handle), event_hooks={"request": [record_request]} + ) as client: + yield client, requests, writes + + +def _tool(client: httpx.AsyncClient, principal: str) -> MCPStreamableHTTPTool: + return MCPStreamableHTTPTool( + name=principal, + url="https://mcp.example/mcp", + http_client=client, + load_prompts=False, + header_provider=lambda kwargs: {"Authorization": kwargs.get("credential", principal)}, + ) + + +def _calls(requests: list[httpx.Request]) -> list[httpx.Request]: + return [ + request + for request in requests + if request.method == "POST" and json.loads(request.content).get("method") == "tools/call" + ] + + +@pytest.mark.parametrize("principals", [("token-a", "token-b"), ("token-b", "token-a")]) +async def test_shared_client_keeps_tools_and_caller_requests_isolated( + mcp_http_server: MCPHTTPServer, principals: tuple[str, str] +) -> None: + client, requests, writes = mcp_http_server + original_hooks = list(client.event_hooks["request"]) + first, second = (_tool(client, principal) for principal in principals) + + async with first: + await first.call_tool("record") + async with second: + await second.call_tool("record") + await first.call_tool("record", marker="first-only") + await client.get("https://mcp.example/unrelated") + assert "Authorization" not in requests[-1].headers + await first.call_tool("record") + await first.connect(reset=True) + await first.call_tool("record") + assert len(client.event_hooks["request"]) == len(original_hooks) + 1 + + calls = _calls(requests) + assert [request.headers["Authorization"] for request in calls] == [ + principals[0], + principals[1], + principals[0], + principals[0], + principals[0], + ] + assert calls[2].headers["mcp-session-id"] == f"session-{principals[0]}" + assert all( + request.headers["mcp-session-id"] == f"session-{request.headers['Authorization']}" + for request in requests + if "mcp-session-id" in request.headers + ) + assert writes[principals[0]] == ["first-only"] + assert writes[principals[1]] == [] + assert client.event_hooks["request"] == original_hooks + assert not client.is_closed + await client.get("https://mcp.example/unrelated") + assert "Authorization" not in requests[-1].headers + + +async def test_closing_another_tool_does_not_skip_inflight_request_hooks(mcp_http_server: MCPHTTPServer) -> None: + client, requests, _ = mcp_http_server + started = asyncio.Event() + release = asyncio.Event() + + async def pause_call(request: httpx.Request) -> None: + if request.method == "POST" and json.loads(request.content).get("method") == "tools/call": + started.set() + await release.wait() + + async with _tool(client, "token-a") as first: + client.event_hooks["request"].append(pause_call) + async with _tool(client, "token-b") as second: + call = asyncio.create_task(second.call_tool("record")) + try: + await asyncio.wait_for(started.wait(), timeout=5) + await first.close() + finally: + release.set() + result = await call + assert isinstance(result, list) + assert result[0].text == "token-b" + assert _calls(requests)[-1].headers["Authorization"] == "token-b" + assert pause_call in client.event_hooks["request"] + + +@pytest.mark.parametrize("failure", ["entry", "cancellation", "initialize"]) +@pytest.mark.parametrize("owned_client", [False, True]) +async def test_transport_failure_cleans_up_hooks_and_owned_client( + mcp_http_server: MCPHTTPServer, failure: str, owned_client: bool +) -> None: + client, _, _ = mcp_http_server + original_hooks = list(client.event_hooks["request"]) + tool = _tool(client, "token-a") + if owned_client: + tool = MCPStreamableHTTPTool( + name="owned", url="https://mcp.example/mcp", header_provider=lambda _: {"Authorization": "token-a"} + ) + if failure == "initialize": + tool = MCPStreamableHTTPTool( + name="invalid", + url="https://mcp.example/mcp", + http_client=None if owned_client else client, + header_provider=lambda _: {"Authorization": "invalid-token"}, + ) + + @contextlib.asynccontextmanager + async def transport(**kwargs: Any) -> AsyncGenerator[tuple[()]]: + if failure == "cancellation": + task = asyncio.current_task() + assert task is not None + task.cancel() + await asyncio.sleep(0) + raise RuntimeError("transport entry failed") + yield () + + error = asyncio.CancelledError if failure == "cancellation" and sys.version_info >= (3, 11) else ToolException + transport_patch = ( + contextlib.nullcontext() + if failure == "initialize" + else patch("agent_framework._mcp.streamable_http_client", side_effect=transport) + ) + try: + with transport_patch, patch("httpx.AsyncClient", return_value=client), pytest.raises(error): + await tool.connect() + assert client.event_hooks["request"] == original_hooks + assert client.is_closed is owned_client + finally: + await tool.close() + + +async def test_owned_client_is_closed_after_successful_session(mcp_http_server: MCPHTTPServer) -> None: + client, _, _ = mcp_http_server + original_hooks = list(client.event_hooks["request"]) + tool = MCPStreamableHTTPTool( + name="owned", + url="https://mcp.example/mcp", + load_prompts=False, + header_provider=lambda _: {"Authorization": "token-a"}, + ) + with patch("httpx.AsyncClient", return_value=client): + async with tool: + await tool.call_tool("record") + assert client.is_closed + assert client.event_hooks["request"] == original_hooks + + +async def test_connecting_another_tool_during_a_call_does_not_capture_its_headers( + mcp_http_server: MCPHTTPServer, +) -> None: + client, requests, _ = mcp_http_server + second = _tool(client, "token-b") + connected = False + + async def connect_second(request: httpx.Request) -> None: + nonlocal connected + if not connected and request.method == "POST" and json.loads(request.content).get("method") == "tools/call": + connected = True + await second.connect() + + client.event_hooks["request"].append(connect_second) + try: + async with _tool(client, "token-a") as first: + await first.call_tool("record", credential="token-c") + await second.call_tool("record") + assert [request.headers["Authorization"] for request in _calls(requests)] == ["token-c", "token-b"] + second_initializes = [ + request + for request in requests + if request.method == "POST" + and json.loads(request.content).get("method") == "initialize" + and request.headers["Authorization"] == "token-b" + ] + assert len(second_initializes) == 1 + finally: + await second.close() + assert not client.is_closed + await client.get("https://mcp.example/unrelated") + assert "Authorization" not in requests[-1].headers + + +async def test_shared_client_concurrent_calls_keep_dynamic_headers_isolated(mcp_http_server: MCPHTTPServer) -> None: + client, requests, writes = mcp_http_server + async with _tool(client, "token-a") as first, _tool(client, "token-b") as second: + await asyncio.gather( + first.call_tool("record", credential="token-c", marker="first"), + second.call_tool("record", credential="token-b", marker="second"), + ) + calls = _calls(requests) + assert {request.headers["mcp-session-id"]: request.headers["Authorization"] for request in calls} == { + "session-token-a": "token-c", + "session-token-b": "token-b", + } + assert writes == {"token-a": [], "token-b": ["second"], "token-c": ["first"]} + assert all("credential" not in json.loads(request.content)["params"]["arguments"] for request in calls) + + +async def test_headerless_transport_does_not_inherit_another_transports_credentials( + mcp_http_server: MCPHTTPServer, +) -> None: + client, requests, _ = mcp_http_server + second = MCPStreamableHTTPTool( + name="unauthenticated", url="https://mcp.example/mcp", http_client=client, load_prompts=False + ) + attempted = False + + async def connect_second(request: httpx.Request) -> None: + nonlocal attempted + if not attempted and request.method == "POST" and json.loads(request.content).get("method") == "tools/call": + attempted = True + with pytest.raises(ToolException): + await second.connect() + + client.event_hooks["request"].append(connect_second) + try: + async with _tool(client, "token-a") as first: + await first.call_tool("record") + assert attempted + assert _calls(requests)[-1].headers["Authorization"] == "token-a" + assert any( + request.method == "POST" + and json.loads(request.content).get("method") == "initialize" + and "Authorization" not in request.headers + for request in requests + ) + finally: + await second.close() + + +async def test_failed_connect_removes_only_its_own_authentication_hook(mcp_http_server: MCPHTTPServer) -> None: + client, requests, _ = mcp_http_server + async with _tool(client, "token-a") as first: + original_hooks = list(client.event_hooks["request"]) + failed = _tool(client, "invalid-token") + try: + with pytest.raises(ToolException): + await failed.connect() + assert client.event_hooks["request"] == original_hooks + await first.call_tool("record") + assert _calls(requests)[-1].headers["Authorization"] == "token-a" + finally: + await failed.close() + assert not client.is_closed + + +async def test_prepared_transport_hook_is_removed_on_close(mcp_http_server: MCPHTTPServer) -> None: + client, _, _ = mcp_http_server + original_hooks = list(client.event_hooks["request"]) + tool = _tool(client, "token-a") + tool.get_mcp_client() + tool.get_mcp_client() + await tool.close() + assert client.event_hooks["request"] == original_hooks + + +@pytest.mark.parametrize("discovery_method", ["load_tools", "load_prompts"]) +@pytest.mark.parametrize("entry_method", ["connect", "context_manager"]) +@pytest.mark.parametrize("owned_client", [False, True]) +@pytest.mark.parametrize("failure_type", [ToolExecutionException, asyncio.CancelledError]) +async def test_discovery_failure_cleans_up_resources( + mcp_http_server: MCPHTTPServer, + discovery_method: str, + entry_method: str, + owned_client: bool, + failure_type: type[ToolExecutionException] | type[asyncio.CancelledError], +) -> None: + client, _, _ = mcp_http_server + original_hooks = list(client.event_hooks["request"]) + tool = MCPStreamableHTTPTool( + name="discovery", + url="https://mcp.example/mcp", + http_client=None if owned_client else client, + header_provider=lambda _: {"Authorization": "token-a"}, + ) + failure = failure_type("discovery failed") + try: + with ( + patch("httpx.AsyncClient", return_value=client), + patch.object(tool, discovery_method, new=AsyncMock(side_effect=failure)), + pytest.raises(failure_type, match="discovery failed") as error, + ): + if entry_method == "connect": + await tool.connect() + else: + async with tool: + pytest.fail("Failed discovery must not enter the context manager") + assert error.value is failure + assert client.event_hooks["request"] == original_hooks + assert client.is_closed is owned_client + assert tool.session is None + assert not tool.is_connected + assert not tool._tools_loaded + assert not tool._prompts_loaded + finally: + await tool.close() + + +@pytest.mark.parametrize("discovery_method", ["tools/list", "prompts/list"]) +@pytest.mark.parametrize("owned_client", [False, True]) +async def test_discovery_failure_retry_starts_a_fresh_session( + discovery_method: Literal["tools/list", "prompts/list"], owned_client: bool +) -> None: + initialize_count = 0 + failure_count = 0 + clients: list[httpx.AsyncClient] = [] + + async def handle(request: httpx.Request) -> httpx.Response: + nonlocal initialize_count, failure_count + if request.method == "GET": + return httpx.Response(405) + if request.method == "DELETE": + return httpx.Response(200) + body = json.loads(request.content) + method = body.get("method") + if method == discovery_method and body.get("params", {}).get("cursor") and failure_count < 2: + failure_count += 1 + return httpx.Response( + 200, json={"jsonrpc": "2.0", "id": body["id"], "error": {"code": -32603, "message": "discovery failed"}} + ) + result: dict[str, Any] = {} + headers: dict[str, str] = {} + if method == "initialize": + initialize_count += 1 + headers["mcp-session-id"] = f"session-{initialize_count}" + result = { + "protocolVersion": body["params"]["protocolVersion"], + "capabilities": {"tools": {}, "prompts": {}}, + "serverInfo": {"name": "discovery-test", "version": "1"}, + } + elif method == "tools/list": + result = { + "tools": [ + { + "name": "record" if failure_count == 2 else "partial_record", + "inputSchema": {"type": "object", "properties": {"marker": {"type": "string"}}}, + "_meta": {"source": "discovery"}, + "execution": {"taskSupport": "optional"}, + } + ] + } + elif method == "prompts/list": + result = {"prompts": [{"name": "partial_prompt"}] if failure_count < 2 else []} + if method == discovery_method and failure_count < 2: + result["nextCursor"] = "next-page" + if "id" not in body: + return httpx.Response(202) + return httpx.Response(200, headers=headers, json={"jsonrpc": "2.0", "id": body["id"], "result": result}) + + async_client = httpx.AsyncClient + + def create_client(**kwargs: Any) -> httpx.AsyncClient: + client = async_client(transport=httpx.MockTransport(handle), **kwargs) + clients.append(client) + return client + + tool = MCPStreamableHTTPTool( + name="discovery", + url="https://mcp.example/mcp", + http_client=None if owned_client else create_client(), + header_provider=lambda _: {"Authorization": "token-a"}, + ) + from mcp.shared.exceptions import McpError + + try: + with patch("httpx.AsyncClient", side_effect=create_client): + for _ in range(2): + with pytest.raises(McpError, match="discovery failed"): + await tool.connect() + assert tool.session is None + assert not tool.is_connected + assert not tool._tools_loaded + assert not tool._prompts_loaded + assert tool.functions == [] + assert tool._tool_call_meta_by_name == {} + assert tool._tool_task_support_by_name == {} + assert tool._tool_param_names_by_name == {} + assert all(not client.event_hooks["request"] for client in clients) + assert all(client.is_closed is owned_client for client in clients) + await tool.connect() + assert tool.is_connected + assert [function.name for function in tool.functions] == ["record"] + assert initialize_count == 3 + assert len(clients) == (3 if owned_client else 1) + await tool.close() + assert all(not client.event_hooks["request"] for client in clients) + assert all(client.is_closed is owned_client for client in clients) + finally: + await tool.close() + for client in clients: + await client.aclose() + + +@pytest.mark.parametrize("blocked_method", ["initialize", "tools/list", "prompts/list"]) +@pytest.mark.parametrize("entry_method", ["connect", "context_manager"]) +@pytest.mark.parametrize("owned_client", [False, True]) +async def test_cancelled_connect_caller_releases_abandoned_resources( + mcp_http_server: MCPHTTPServer, blocked_method: str, entry_method: str, owned_client: bool +) -> None: + client, requests, _ = mcp_http_server + setup_started = asyncio.Event() + release_setup = asyncio.Event() + cleanup_finished = asyncio.Event() + + async def block_setup(request: httpx.Request) -> None: + if request.method == "POST" and json.loads(request.content).get("method") == blocked_method: + setup_started.set() + await release_setup.wait() + + client.event_hooks["request"].append(block_setup) + original_hooks = list(client.event_hooks["request"]) + tool = MCPStreamableHTTPTool( + name="cancelled-caller", + url="https://mcp.example/mcp", + http_client=None if owned_client else client, + header_provider=lambda _: {"Authorization": "token-a"}, + ) + close_on_owner = tool._close_on_owner + + async def record_cleanup() -> None: + await close_on_owner() + cleanup_finished.set() + + async def enter() -> None: + if entry_method == "connect": + await tool.connect() + else: + async with tool: + pytest.fail("Cancelled setup must not enter the context manager") + + with patch("httpx.AsyncClient", return_value=client), patch.object(tool, "_close_on_owner", record_cleanup): + caller = asyncio.create_task(enter()) + try: + await asyncio.wait_for(setup_started.wait(), timeout=5) + caller.cancel() + with pytest.raises(asyncio.CancelledError): + await caller + assert not client.is_closed + assert tool._lifecycle_owner_task is not None + assert not tool._lifecycle_owner_task.done() + + release_setup.set() + await asyncio.wait_for(cleanup_finished.wait(), timeout=5) + assert not tool.is_connected + assert tool.session is None + assert tool._lifecycle_owner_task is None + assert client.event_hooks["request"] == original_hooks + assert client.is_closed is owned_client + assert any( + request.method == "DELETE" and request.headers.get("Authorization") == "token-a" for request in requests + ) + finally: + release_setup.set() + await tool.close() + + +async def test_cancelled_queued_connect_does_not_start_transport(mcp_http_server: MCPHTTPServer) -> None: + client, requests, _ = mcp_http_server + tool = _tool(client, "token-a") + caller = asyncio.create_task(tool.connect()) + # This runs before the lifecycle owner created by connect() gets its first turn. + asyncio.get_running_loop().call_soon(caller.cancel, None) + with pytest.raises(asyncio.CancelledError): + await caller + assert tool._lifecycle_owner_task is None + await tool.close() + assert requests == [] + + +async def test_connect_cancelled_during_result_delivery_releases_session(mcp_http_server: MCPHTTPServer) -> None: + client, _, _ = mcp_http_server + original_hooks = list(client.event_hooks["request"]) + tool = _tool(client, "token-a") + cleanup_finished = asyncio.Event() + connect_on_owner = tool._connect_on_owner + close_on_owner = tool._close_on_owner + + async def cancel_before_delivery(*, reset: bool = False, load_configured: bool = True) -> None: + await connect_on_owner(reset=reset, load_configured=load_configured) + # Setup succeeds, then the caller is cancelled before consuming the completed future. + asyncio.get_running_loop().call_soon(caller.cancel, None) + + async def record_cleanup() -> None: + await close_on_owner() + cleanup_finished.set() + + with ( + patch.object(tool, "_connect_on_owner", cancel_before_delivery), + patch.object(tool, "_close_on_owner", record_cleanup), + ): + caller = asyncio.create_task(tool.connect()) + try: + with pytest.raises(asyncio.CancelledError): + await caller + await asyncio.wait_for(cleanup_finished.wait(), timeout=5) + assert tool.session is None + assert not tool.is_connected + assert tool._lifecycle_owner_task is None + assert client.event_hooks["request"] == original_hooks + finally: + await tool.close() + + +async def test_abandoned_connect_is_cleaned_before_next_caller(mcp_http_server: MCPHTTPServer) -> None: + client, requests, _ = mcp_http_server + setup_started = asyncio.Event() + release_setup = asyncio.Event() + + async def block_setup(request: httpx.Request) -> None: + if request.method == "POST" and json.loads(request.content).get("method") == "tools/list": + setup_started.set() + await release_setup.wait() + + client.event_hooks["request"].append(block_setup) + original_hooks = list(client.event_hooks["request"]) + tool = _tool(client, "token-a") + caller = asyncio.create_task(tool.connect()) + try: + await asyncio.wait_for(setup_started.wait(), timeout=5) + abandoned_session = tool.session + caller.cancel() + with pytest.raises(asyncio.CancelledError): + await caller + retry = asyncio.create_task(tool.connect()) + release_setup.set() + await asyncio.wait_for(retry, timeout=5) + assert tool.is_connected + assert tool.session is not abandoned_session + assert ( + sum( + request.method == "POST" and json.loads(request.content).get("method") == "initialize" + for request in requests + ) + == 2 + ) + assert len(client.event_hooks["request"]) == len(original_hooks) + 1 + finally: + release_setup.set() + await tool.close() + + +@pytest.mark.parametrize("discovery_method", ["load_tools", "load_prompts"]) +@pytest.mark.parametrize("entry_method", ["connect", "context_manager"]) +@pytest.mark.parametrize("failure_type", [ToolExecutionException, RuntimeError, asyncio.CancelledError]) +async def test_failed_discovery_preserves_caller_supplied_session( + mcp_http_server: MCPHTTPServer, + discovery_method: str, + entry_method: str, + failure_type: type[Exception] | type[asyncio.CancelledError], +) -> None: + client, _, _ = mcp_http_server + async with _tool(client, "token-a") as source: + supplied_session = source.session + assert supplied_session is not None + original_hooks = list(client.event_hooks["request"]) + borrowed = MCPStreamableHTTPTool( + name="borrowed", url="https://must-not-connect.example/mcp", session=supplied_session + ) + failure = failure_type("discovery failed") + expected_error = ( + ToolExecutionException + if entry_method == "context_manager" and failure_type is RuntimeError + else failure_type + ) + with patch.object( + borrowed, "get_mcp_client", Mock(side_effect=AssertionError("Unexpected transport")) + ) as transport: + try: + with ( + patch.object(borrowed, discovery_method, AsyncMock(side_effect=failure)), + pytest.raises(expected_error), + ): + if entry_method == "connect": + await borrowed.connect() + else: + async with borrowed: + pytest.fail("Failed discovery must not enter the context manager") + assert borrowed.session is supplied_session + assert not borrowed.is_connected + assert client.event_hooks["request"] == original_hooks + assert not client.is_closed + await supplied_session.send_ping() + + await borrowed.connect() + assert borrowed.is_connected + assert borrowed.session is supplied_session + await borrowed.connect(reset=True) + assert borrowed.session is supplied_session + transport.assert_not_called() + finally: + await borrowed.close() + assert borrowed.session is supplied_session + await supplied_session.send_ping() + + +async def test_cancelled_redundant_connect_keeps_existing_session(mcp_http_server: MCPHTTPServer) -> None: + client, _, _ = mcp_http_server + async with _tool(client, "token-a") as tool: + existing_session = tool.session + original_hooks = list(client.event_hooks["request"]) + connect_on_owner = tool._connect_on_owner + + async def cancel_before_delivery(*, reset: bool = False, load_configured: bool = True) -> None: + await connect_on_owner(reset=reset, load_configured=load_configured) + asyncio.get_running_loop().call_soon(caller.cancel, None) + + with patch.object(tool, "_connect_on_owner", cancel_before_delivery): + caller = asyncio.create_task(tool.connect()) + with pytest.raises(asyncio.CancelledError): + await caller + # A subsequent request also confirms that the abandoned action finished processing. + await tool.connect() + assert tool.is_connected + assert tool.session is existing_session + assert client.event_hooks["request"] == original_hooks + result = await tool.call_tool("record") + assert isinstance(result, list) + assert result[0].text == "token-a" + + +async def test_cancelled_borrowed_session_caller_can_retry(mcp_http_server: MCPHTTPServer) -> None: + client, _, _ = mcp_http_server + async with _tool(client, "token-a") as source: + supplied_session = source.session + assert supplied_session is not None + borrowed = MCPStreamableHTTPTool( + name="borrowed", url="https://must-not-connect.example/mcp", session=supplied_session + ) + setup_started = asyncio.Event() + release_setup = asyncio.Event() + load_tools = borrowed.load_tools + + async def block_discovery() -> None: + setup_started.set() + await release_setup.wait() + await load_tools() + + with ( + patch.object(borrowed, "load_tools", block_discovery), + patch.object( + borrowed, "get_mcp_client", Mock(side_effect=AssertionError("Unexpected transport")) + ) as transport, + ): + caller = asyncio.create_task(borrowed.connect()) + try: + await asyncio.wait_for(setup_started.wait(), timeout=5) + caller.cancel() + with pytest.raises(asyncio.CancelledError): + await caller + release_setup.set() + await borrowed.connect() + assert borrowed.is_connected + assert borrowed.session is supplied_session + transport.assert_not_called() + await supplied_session.send_ping() + finally: + release_setup.set() + await borrowed.close() diff --git a/python/packages/declarative/tests/test_default_mcp_tool_handler.py b/python/packages/declarative/tests/test_default_mcp_tool_handler.py index 1523f2261f0..a8329dea278 100644 --- a/python/packages/declarative/tests/test_default_mcp_tool_handler.py +++ b/python/packages/declarative/tests/test_default_mcp_tool_handler.py @@ -2,12 +2,15 @@ """Tests for ``DefaultMCPToolHandler``. -These tests exercise the real handler against a fake ``MCPStreamableHTTPTool`` +Most tests exercise the real handler against a fake ``MCPStreamableHTTPTool`` (no real MCP server, no real network) to cover the parts of the handler not exercisable through the executor stub: cache hit/miss/eviction, concurrent connect via in-flight futures, header isolation across cache keys, string-result normalisation, ``load_prompts=False`` verification, and owned-vs-caller httpx close semantics. + +The shared-client regression also exercises the real MCP SDK transport against +an in-process HTTPX mock server. """ from __future__ import annotations @@ -147,6 +150,83 @@ def _invocation( ) +@pytest.mark.parametrize("cache_max_size", [1, 2]) +async def test_shared_client_isolates_cached_authentication_and_cleans_up_hooks(cache_max_size: int) -> None: + sessions: dict[str, str] = {} + calls: list[tuple[str, str]] = [] + writes: dict[str, list[str]] = {"token-a": [], "token-b": []} + + async def caller_hook(request: httpx.Request) -> None: + request.headers["X-Caller"] = "preserved" + + async def handle(request: httpx.Request) -> httpx.Response: + assert request.headers["X-Caller"] == "preserved" + principal = request.headers.get("Authorization", "") + if principal not in writes: + return httpx.Response(401) + if request.method == "GET": + return httpx.Response(405) + if request.method == "DELETE": + return httpx.Response(200) + body = json.loads(request.content) + headers: dict[str, str] = {} + result: dict[str, Any] = {} + if body.get("method") == "initialize": + session_id = f"session-{len(sessions)}" + sessions[session_id] = principal + headers["mcp-session-id"] = session_id + result = { + "protocolVersion": body["params"]["protocolVersion"], + "capabilities": {"tools": {}}, + "serverInfo": {"name": "auth-test", "version": "1"}, + } + elif body.get("method") == "tools/list": + result = { + "tools": [ + { + "name": "search", + "inputSchema": {"type": "object", "properties": {"marker": {"type": "string"}}}, + } + ] + } + elif body.get("method") == "tools/call": + calls.append((request.headers["mcp-session-id"], principal)) + if marker := body["params"].get("arguments", {}).get("marker"): + writes[principal].append(marker) + result = {"content": [{"type": "text", "text": principal}]} + if "id" not in body: + return httpx.Response(202) + return httpx.Response(200, headers=headers, json={"jsonrpc": "2.0", "id": body["id"], "result": result}) + + async with httpx.AsyncClient( + transport=httpx.MockTransport(handle), event_hooks={"request": [caller_hook]} + ) as client: + + async def client_provider(invocation: MCPToolInvocation) -> httpx.AsyncClient: + return client + + async with DefaultMCPToolHandler(client_provider=client_provider, cache_max_size=cache_max_size) as handler: + outputs: list[str | None] = [] + for index, principal in enumerate(("token-a", "token-b", "token-a")): + result = await handler.invoke_tool( + _invocation( + headers={"Authorization": principal}, + arguments={"marker": "a-only"} if index == 2 else {}, + ) + ) + assert not result.is_error + outputs.append(result.outputs[0].text) + assert outputs == ["token-a", "token-b", "token-a"] + assert len(client.event_hooks["request"]) == 1 + cache_max_size + + assert [principal for _, principal in calls] == ["token-a", "token-b", "token-a"] + assert all(sessions[session_id] == principal for session_id, principal in calls) + assert len(sessions) == (3 if cache_max_size == 1 else 2) + assert writes == {"token-a": ["a-only"], "token-b": []} + assert client.event_hooks["request"] == [caller_hook] + assert not client.is_closed + + # ---------- Construction --------------------------------------------------- From 73d6c584a3c65c33e635c7d841afa0d45a7cdc91 Mon Sep 17 00:00:00 2001 From: Atharva Vichare <99388680+atty57@users.noreply.github.com> Date: Tue, 8 Sep 2026 06:36:02 +0000 Subject: [PATCH 74/85] Python: Fix A2AAgent AttributeError on exit with a caller-supplied http_client (#7951) * Python: Fix A2AAgent AttributeError on exit with a caller-supplied http_client `A2AAgent.__init__` assigned `_close_http_client` on only two of its three constructor paths. Passing `http_client=` without `client=` left the attribute unset, so `__aexit__` raised `AttributeError` when the agent was used as an async context manager - the exact usage the `http_client` docstring invites. Default `_close_http_client` to False before the branching logic. Every path now has a safe value, and a caller-supplied client is correctly left open for its owner to close. Fixes #7950 * Comment changes Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- python/packages/a2a/agent_framework_a2a/_agent.py | 1 + python/packages/a2a/tests/test_a2a_agent.py | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/python/packages/a2a/agent_framework_a2a/_agent.py b/python/packages/a2a/agent_framework_a2a/_agent.py index 1261305bfbc..1a143aca33f 100644 --- a/python/packages/a2a/agent_framework_a2a/_agent.py +++ b/python/packages/a2a/agent_framework_a2a/_agent.py @@ -263,6 +263,7 @@ def __init__( super().__init__(id=id, name=name, description=description, **kwargs) self._http_client: httpx.AsyncClient | None = http_client + # By default, leave a caller-supplied HTTP client open; specific paths may override this below. # every construction path must set this before __aexit__ can run self._close_http_client = False self._timeout_config = self._create_timeout_config(timeout) diff --git a/python/packages/a2a/tests/test_a2a_agent.py b/python/packages/a2a/tests/test_a2a_agent.py index 9c111a2b11e..049d2a45b0f 100644 --- a/python/packages/a2a/tests/test_a2a_agent.py +++ b/python/packages/a2a/tests/test_a2a_agent.py @@ -465,6 +465,16 @@ async def test_context_manager_no_cleanup_when_no_http_client() -> None: pass +async def test_context_manager_does_not_close_caller_supplied_http_client() -> None: + """A caller-supplied http_client (without client=) must survive __aexit__. See issue #7950.""" + mock_http_client = AsyncMock() + + async with A2AAgent(url="http://localhost:9999/", http_client=cast(Any, mock_http_client)): + pass + + mock_http_client.aclose.assert_not_called() + + def test_prepare_message_for_a2a_with_multiple_contents() -> None: """Test conversion of Message with multiple contents.""" From 05911fea70b5cf1b0728b186faa8d36883d086c9 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Tue, 8 Sep 2026 06:57:10 +0000 Subject: [PATCH 75/85] Python: add moonbox3 as code owner of all packages (#8136) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/CODEOWNERS | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3b04ba06be6..540ff46dd8d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -52,8 +52,8 @@ /python/packages/a2a/ @chetantoshniwal @giles17 @eavanvalkenburg @moonbox3 /python/packages/ag-ui/ @chetantoshniwal @eavanvalkenburg @moonbox3 @giles17 /python/packages/anthropic/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 -/python/packages/azure-ai-search/ @chetantoshniwal @eavanvalkenburg @giles17 -/python/packages/azure-contentunderstanding/ @chetantoshniwal @giles17 @eavanvalkenburg +/python/packages/azure-ai-search/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 +/python/packages/azure-contentunderstanding/ @chetantoshniwal @giles17 @eavanvalkenburg @moonbox3 /python/packages/azure-cosmos/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 /python/packages/azure-cosmos-memory/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 /python/packages/bedrock/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 From 1d18f7c5dedb7133690a739a87833530367ce4a6 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Tue, 8 Sep 2026 07:04:50 +0000 Subject: [PATCH 76/85] [BREAKING] Python: Refine SecretString handling (#8127) * [BREAKING] Python: Refine SecretString handling Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: Address SecretString review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: Address additional SecretString feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../_bedrock_client.py | 12 +- .../agent_framework_anthropic/_chat_client.py | 4 +- .../_foundry_client.py | 4 +- .../_vertex_client.py | 11 +- .../anthropic/tests/test_anthropic_client.py | 20 +- .../tests/test_anthropic_provider_clients.py | 32 +++- .../_context_provider.py | 26 +-- .../tests/test_aisearch_context_provider.py | 31 ++- .../_checkpoint_storage.py | 6 +- .../_history_provider.py | 6 +- .../tests/test_cosmos_checkpoint_storage.py | 22 ++- .../tests/test_cosmos_history_provider.py | 20 +- .../agent_framework_bedrock/_chat_client.py | 6 +- .../_embedding_client.py | 12 +- .../bedrock/tests/test_bedrock_client.py | 37 +++- .../agent_framework_copilotstudio/_agent.py | 6 +- .../copilotstudio/tests/test_copilot_agent.py | 15 +- python/packages/core/AGENTS.md | 8 + .../core/agent_framework/_settings.py | 96 ++++++++-- .../packages/core/tests/core/test_sessions.py | 13 ++ .../packages/core/tests/core/test_settings.py | 177 +++++++++++++++++- .../core/tests/workflow/test_state.py | 9 + .../_embedding_client.py | 11 +- .../foundry/test_foundry_embedding_client.py | 21 ++- .../agent_framework_gemini/_chat_client.py | 4 +- .../gemini/tests/test_gemini_client.py | 21 ++- .../agent_framework_mem0/_context_provider.py | 7 +- .../mem0/tests/test_mem0_context_provider.py | 8 +- .../tests/mistral/test_mistral_chat_client.py | 10 +- .../mistral/test_mistral_embedding_client.py | 10 +- .../agent_framework_openai/_chat_client.py | 6 +- .../_chat_completion_client.py | 6 +- .../_embedding_client.py | 6 +- .../openai/test_openai_embedding_client.py | 13 +- .../openai/tests/openai/test_openai_shared.py | 32 ++++ 35 files changed, 605 insertions(+), 123 deletions(-) diff --git a/python/packages/anthropic/agent_framework_anthropic/_bedrock_client.py b/python/packages/anthropic/agent_framework_anthropic/_bedrock_client.py index a63e3012bf0..215382b33a8 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_bedrock_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_bedrock_client.py @@ -40,11 +40,11 @@ def __init__( self, *, model: str | None = None, - aws_secret_key: str | None = None, - aws_access_key: str | None = None, + aws_secret_key: str | SecretString | None = None, + aws_access_key: str | SecretString | None = None, aws_region: str | None = None, aws_profile: str | None = None, - aws_session_token: str | None = None, + aws_session_token: str | SecretString | None = None, base_url: str | None = None, anthropic_client: AsyncAnthropicBedrock | None = None, additional_beta_flags: list[str] | None = None, @@ -118,11 +118,11 @@ def __init__( self, *, model: str | None = None, - aws_secret_key: str | None = None, - aws_access_key: str | None = None, + aws_secret_key: str | SecretString | None = None, + aws_access_key: str | SecretString | None = None, aws_region: str | None = None, aws_profile: str | None = None, - aws_session_token: str | None = None, + aws_session_token: str | SecretString | None = None, base_url: str | None = None, anthropic_client: AsyncAnthropicBedrock | None = None, additional_beta_flags: list[str] | None = None, diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index 7b0cf4eb792..cefc50b1223 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -297,7 +297,7 @@ class RawAnthropicClient( def __init__( self, *, - api_key: str | None = None, + api_key: str | SecretString | None = None, model: str | None = None, base_url: str | None = None, anthropic_client: AnthropicAsyncClient | None = None, @@ -1671,7 +1671,7 @@ class AnthropicClient( def __init__( self, *, - api_key: str | None = None, + api_key: str | SecretString | None = None, model: str | None = None, base_url: str | None = None, anthropic_client: AnthropicAsyncClient | None = None, diff --git a/python/packages/anthropic/agent_framework_anthropic/_foundry_client.py b/python/packages/anthropic/agent_framework_anthropic/_foundry_client.py index 2a9db1177ff..2b1ee0cd405 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_foundry_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_foundry_client.py @@ -40,7 +40,7 @@ def __init__( *, model: str | None = None, resource: str | None = None, - api_key: str | None = None, + api_key: str | SecretString | None = None, azure_ad_token_provider: AnthropicFoundryAzureADTokenProvider | None = None, base_url: str | None = None, anthropic_client: AsyncAnthropicFoundry | None = None, @@ -123,7 +123,7 @@ def __init__( *, model: str | None = None, resource: str | None = None, - api_key: str | None = None, + api_key: str | SecretString | None = None, azure_ad_token_provider: AnthropicFoundryAzureADTokenProvider | None = None, base_url: str | None = None, anthropic_client: AsyncAnthropicFoundry | None = None, diff --git a/python/packages/anthropic/agent_framework_anthropic/_vertex_client.py b/python/packages/anthropic/agent_framework_anthropic/_vertex_client.py index 77edeb5f529..71e9b8ef69c 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_vertex_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_vertex_client.py @@ -11,7 +11,7 @@ FunctionInvocationConfiguration, FunctionInvocationLayer, ) -from agent_framework._settings import load_settings +from agent_framework._settings import SecretString, load_settings from agent_framework._telemetry import get_user_agent from agent_framework.observability import ChatTelemetryLayer from anthropic import NOT_GIVEN @@ -43,7 +43,7 @@ def __init__( model: str | None = None, region: str | None = None, project_id: str | None = None, - access_token: str | None = None, + access_token: str | SecretString | None = None, credentials: GoogleCredentials | None = None, base_url: str | None = None, anthropic_client: AsyncAnthropicVertex | None = None, @@ -84,10 +84,13 @@ def __init__( if anthropic_client is None: resolved_region = region_setting if region_setting is not None else NOT_GIVEN resolved_project_id = project_id_setting if project_id_setting is not None else NOT_GIVEN + resolved_access_token = ( + access_token.get_secret_value() if isinstance(access_token, SecretString) else access_token + ) anthropic_client = AsyncAnthropicVertex( region=resolved_region, project_id=resolved_project_id, - access_token=access_token, + access_token=resolved_access_token, credentials=credentials, base_url=settings.get("anthropic_vertex_base_url"), default_headers={"User-Agent": get_user_agent()}, @@ -116,7 +119,7 @@ def __init__( model: str | None = None, region: str | None = None, project_id: str | None = None, - access_token: str | None = None, + access_token: str | SecretString | None = None, credentials: GoogleCredentials | None = None, base_url: str | None = None, anthropic_client: AsyncAnthropicVertex | None = None, diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index bd9eba7f16b..91d8d8b7187 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -23,7 +23,7 @@ SupportsChatGetResponse, tool, ) -from agent_framework._settings import load_settings +from agent_framework._settings import SecretString, load_settings from agent_framework._tools import SHELL_TOOL_KIND_VALUE from agent_framework.exceptions import ( ChatClientException, @@ -101,17 +101,20 @@ def test_anthropic_settings_init(anthropic_unit_test_env: dict[str, str]) -> Non assert settings["chat_model"] == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"] -def test_anthropic_settings_init_with_explicit_values() -> None: +@pytest.mark.parametrize("api_key", ["custom-api-key", SecretString("custom-api-key")], ids=["str", "secret"]) +def test_anthropic_settings_init_with_explicit_values(api_key: str | SecretString) -> None: """Test AnthropicSettings initialization with explicit values.""" settings = load_settings( AnthropicSettings, env_prefix="ANTHROPIC_", - api_key="custom-api-key", + api_key=api_key, chat_model="claude-3-opus-20240229", ) - assert settings["api_key"] is not None + assert isinstance(settings["api_key"], SecretString) assert settings["api_key"].get_secret_value() == "custom-api-key" + assert "custom-api-key" not in str(settings["api_key"]) + assert "custom-api-key" not in repr(settings) assert settings["chat_model"] == "claude-3-opus-20240229" @@ -160,16 +163,21 @@ def test_agent_accepts_anthropic_clients() -> None: assert agent.client is client +@pytest.mark.parametrize("secret_type", [str, SecretString], ids=["str", "secret"]) def test_anthropic_client_init_auto_create_client( anthropic_unit_test_env: dict[str, str], + secret_type: type[str] | type[SecretString], ) -> None: """Test AnthropicClient initialization with auto-created anthropic_client.""" client = AnthropicClient( - api_key=anthropic_unit_test_env["ANTHROPIC_API_KEY"], + api_key=secret_type(anthropic_unit_test_env["ANTHROPIC_API_KEY"]), model=anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"], ) - assert client.anthropic_client is not None + anthropic_client = client.anthropic_client + assert isinstance(anthropic_client, anthropic_sdk.AsyncAnthropic) + assert type(anthropic_client.api_key) is str + assert anthropic_client.api_key == anthropic_unit_test_env["ANTHROPIC_API_KEY"] assert client.model == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL"] diff --git a/python/packages/anthropic/tests/test_anthropic_provider_clients.py b/python/packages/anthropic/tests/test_anthropic_provider_clients.py index 9dce241ea84..d637aedef96 100644 --- a/python/packages/anthropic/tests/test_anthropic_provider_clients.py +++ b/python/packages/anthropic/tests/test_anthropic_provider_clients.py @@ -4,6 +4,7 @@ import pytest from agent_framework import Agent, ChatMiddlewareLayer, FunctionInvocationLayer +from agent_framework._settings import SecretString from agent_framework._telemetry import get_user_agent from agent_framework.observability import ChatTelemetryLayer @@ -107,7 +108,10 @@ def test_agent_accepts_anthropic_vertex_clients() -> None: assert agent.client is client -def test_raw_anthropic_foundry_client_creates_sdk_client_from_settings(tmp_path) -> None: +@pytest.mark.parametrize("api_key", [None, "test-key", SecretString("test-key")], ids=["env", "str", "secret"]) +def test_raw_anthropic_foundry_client_creates_sdk_client_from_settings( + tmp_path, api_key: str | SecretString | None +) -> None: env_file = tmp_path / ".env" env_file.write_text( "ANTHROPIC_CHAT_MODEL=claude-foundry-test\n" @@ -119,10 +123,11 @@ def test_raw_anthropic_foundry_client_creates_sdk_client_from_settings(tmp_path) with patch( "agent_framework_anthropic._foundry_client.AsyncAnthropicFoundry", return_value=mock_transport ) as factory: - client = RawAnthropicFoundryClient(env_file_path=str(env_file)) + client = RawAnthropicFoundryClient(api_key=api_key, env_file_path=str(env_file)) assert client.model == "claude-foundry-test" assert client.anthropic_client is mock_transport + assert type(factory.call_args.kwargs["api_key"]) is str factory.assert_called_once_with( resource="test-resource", api_key="test-key", @@ -174,7 +179,10 @@ def test_raw_anthropic_foundry_client_requires_resource_or_base_url() -> None: RawAnthropicFoundryClient() -def test_raw_anthropic_bedrock_client_creates_sdk_client_from_arguments() -> None: +@pytest.mark.parametrize("secret_type", [str, SecretString], ids=["str", "secret"]) +def test_raw_anthropic_bedrock_client_creates_sdk_client_from_arguments( + secret_type: type[str] | type[SecretString], +) -> None: mock_transport = _create_mock_transport("https://bedrock-runtime.us-east-1.amazonaws.com") with patch( @@ -182,8 +190,9 @@ def test_raw_anthropic_bedrock_client_creates_sdk_client_from_arguments() -> Non ) as factory: client = RawAnthropicBedrockClient( model="claude-bedrock-test", - aws_access_key="access-key", - aws_secret_key="secret-key", + aws_access_key=secret_type("access-key"), + aws_secret_key=secret_type("secret-key"), + aws_session_token=secret_type("session-token"), aws_region="us-east-1", ) @@ -194,13 +203,18 @@ def test_raw_anthropic_bedrock_client_creates_sdk_client_from_arguments() -> Non aws_access_key="access-key", aws_region="us-east-1", aws_profile=None, - aws_session_token=None, + aws_session_token="session-token", base_url=None, default_headers={"User-Agent": get_user_agent()}, ) + for key in ("aws_access_key", "aws_secret_key", "aws_session_token"): + assert type(factory.call_args.kwargs[key]) is str -def test_raw_anthropic_vertex_client_creates_sdk_client_from_arguments() -> None: +@pytest.mark.parametrize("access_token", ["access-token", SecretString("access-token")], ids=["str", "secret"]) +def test_raw_anthropic_vertex_client_creates_sdk_client_from_arguments( + access_token: str | SecretString, +) -> None: mock_transport = _create_mock_transport("https://us-central1-aiplatform.googleapis.com/v1") with patch("agent_framework_anthropic._vertex_client.AsyncAnthropicVertex", return_value=mock_transport) as factory: @@ -208,6 +222,7 @@ def test_raw_anthropic_vertex_client_creates_sdk_client_from_arguments() -> None model="claude-vertex-test", region="us-central1", project_id="test-project", + access_token=access_token, ) assert client.model == "claude-vertex-test" @@ -215,8 +230,9 @@ def test_raw_anthropic_vertex_client_creates_sdk_client_from_arguments() -> None factory.assert_called_once_with( region="us-central1", project_id="test-project", - access_token=None, + access_token="access-token", credentials=None, base_url=None, default_headers={"User-Agent": get_user_agent()}, ) + assert type(factory.call_args.kwargs["access_token"]) is str diff --git a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py index faabd6d7bdd..3bcd83c6619 100644 --- a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py +++ b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py @@ -188,7 +188,7 @@ def __init__( source_id: str = DEFAULT_SOURCE_ID, endpoint: str | None = None, index_name: str | None = None, - api_key: str | AzureKeyCredential | None = None, + api_key: str | SecretString | AzureKeyCredential | None = None, credential: AzureCredentialTypes | None = None, *, mode: Literal["semantic"] = "semantic", @@ -201,7 +201,7 @@ def __init__( model: str | None = None, knowledge_base_name: None = None, retrieval_instructions: str | None = None, - azure_openai_api_key: str | None = None, + azure_openai_api_key: str | SecretString | None = None, knowledge_base_output_mode: KnowledgeBaseOutputModeLiteral = "extractive_data", retrieval_reasoning_effort: RetrievalReasoningEffortLiteral = "minimal", query_source_credential: AzureCredentialTypes | None = None, @@ -243,7 +243,7 @@ def __init__( source_id: str = DEFAULT_SOURCE_ID, endpoint: str | None = None, index_name: str | None = None, - api_key: str | AzureKeyCredential | None = None, + api_key: str | SecretString | AzureKeyCredential | None = None, credential: AzureCredentialTypes | None = None, *, mode: Literal["agentic"], @@ -256,7 +256,7 @@ def __init__( model: str, knowledge_base_name: None = None, retrieval_instructions: str | None = None, - azure_openai_api_key: str | None = None, + azure_openai_api_key: str | SecretString | None = None, knowledge_base_output_mode: KnowledgeBaseOutputModeLiteral = "extractive_data", retrieval_reasoning_effort: RetrievalReasoningEffortLiteral = "minimal", query_source_credential: AzureCredentialTypes | None = None, @@ -299,7 +299,7 @@ def __init__( source_id: str = DEFAULT_SOURCE_ID, endpoint: str | None = None, index_name: None = None, - api_key: str | AzureKeyCredential | None = None, + api_key: str | SecretString | AzureKeyCredential | None = None, credential: AzureCredentialTypes | None = None, *, mode: Literal["agentic"], @@ -312,7 +312,7 @@ def __init__( model: str | None = None, knowledge_base_name: str, retrieval_instructions: str | None = None, - azure_openai_api_key: str | None = None, + azure_openai_api_key: str | SecretString | None = None, knowledge_base_output_mode: KnowledgeBaseOutputModeLiteral = "extractive_data", retrieval_reasoning_effort: RetrievalReasoningEffortLiteral = "minimal", query_source_credential: AzureCredentialTypes | None = None, @@ -355,7 +355,7 @@ def __init__( source_id: str = DEFAULT_SOURCE_ID, endpoint: str | None = None, index_name: None = None, - api_key: str | AzureKeyCredential | None = None, + api_key: str | SecretString | AzureKeyCredential | None = None, credential: AzureCredentialTypes | None = None, *, mode: Literal["agentic"], @@ -368,7 +368,7 @@ def __init__( model: str | None = None, knowledge_base_name: None = None, retrieval_instructions: str | None = None, - azure_openai_api_key: str | None = None, + azure_openai_api_key: str | SecretString | None = None, knowledge_base_output_mode: KnowledgeBaseOutputModeLiteral = "extractive_data", retrieval_reasoning_effort: RetrievalReasoningEffortLiteral = "minimal", query_source_credential: AzureCredentialTypes | None = None, @@ -414,7 +414,7 @@ def __init__( source_id: str = DEFAULT_SOURCE_ID, endpoint: str | None = None, index_name: str | None = None, - api_key: str | AzureKeyCredential | None = None, + api_key: str | SecretString | AzureKeyCredential | None = None, credential: AzureCredentialTypes | None = None, *, mode: Literal["semantic", "agentic"] = "semantic", @@ -427,7 +427,7 @@ def __init__( model: str | None = None, knowledge_base_name: str | None = None, retrieval_instructions: str | None = None, - azure_openai_api_key: str | None = None, + azure_openai_api_key: str | SecretString | None = None, knowledge_base_output_mode: KnowledgeBaseOutputModeLiteral = "extractive_data", retrieval_reasoning_effort: RetrievalReasoningEffortLiteral = "minimal", query_source_credential: AzureCredentialTypes | None = None, @@ -499,7 +499,7 @@ def __init__( endpoint=endpoint, index_name=index_name, knowledge_base_name=knowledge_base_name, - api_key=api_key if isinstance(api_key, str) else None, + api_key=api_key if isinstance(api_key, (str, SecretString)) else None, env_file_path=env_file_path, env_file_encoding=env_file_encoding, ) @@ -866,7 +866,9 @@ async def _ensure_knowledge_base(self) -> None: resource_url=self.azure_openai_resource_url, deployment_name=self.azure_openai_model, model_name=self.azure_openai_model, - api_key=self.azure_openai_api_key, + api_key=self.azure_openai_api_key.get_secret_value() + if isinstance(self.azure_openai_api_key, SecretString) + else self.azure_openai_api_key, ) kb_kwargs: dict[str, Any] = { diff --git a/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py b/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py index 3672b6e2016..776950974a2 100644 --- a/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py +++ b/python/packages/azure-ai-search/tests/test_aisearch_context_provider.py @@ -12,6 +12,7 @@ import pytest from agent_framework import Content, Message from agent_framework._sessions import AgentSession, SessionContext +from agent_framework._settings import SecretString from agent_framework.exceptions import SettingNotFoundError from azure.core.credentials import AzureKeyCredential from azure.core.pipeline.transport import AioHttpTransport @@ -205,6 +206,9 @@ def test_env_variable_fallback(self) -> None: provider = AzureAISearchContextProvider(source_id="env-test") assert provider.endpoint == "https://env.search.windows.net" assert provider.index_name == "env-index" + assert isinstance(provider.credential, AzureKeyCredential) + assert type(provider.credential.key) is str + assert provider.credential.key == "env-key" def test_top_k_and_semantic_config(self) -> None: provider = _make_provider(top_k=10, semantic_configuration_name="my-config") @@ -235,6 +239,18 @@ def test_model_explicit(self) -> None: class TestInitCredentialResolution: """Tests for credential resolution paths.""" + @pytest.mark.parametrize("api_key", ["test-key", SecretString("test-key")], ids=["str", "secret"]) + def test_api_key_unwrapped(self, api_key: str | SecretString, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AZURE_SEARCH_API_KEY", "env-key") + provider = AzureAISearchContextProvider( + endpoint="https://test.search.windows.net", + index_name="idx", + api_key=api_key, + ) + assert isinstance(provider.credential, AzureKeyCredential) + assert type(provider.credential.key) is str + assert provider.credential.key == "test-key" + def test_token_credential_used(self) -> None: mock_cred = AsyncMock() provider = AzureAISearchContextProvider( @@ -1273,10 +1289,11 @@ async def test_missing_index_name_raises(self) -> None: with pytest.raises(ValueError, match="index_name is required"): await provider._ensure_knowledge_base() - async def test_creates_knowledge_source_when_not_found(self) -> None: + @pytest.mark.parametrize("api_key", [None, "aoai-key", SecretString("aoai-key")], ids=["none", "str", "secret"]) + async def test_creates_knowledge_source_when_not_found(self, api_key: str | SecretString | None) -> None: from azure.core.exceptions import ResourceNotFoundError - provider = _make_provider() + provider = _make_provider(azure_openai_api_key=api_key) provider._knowledge_base_initialized = False provider._use_existing_knowledge_base = False provider.knowledge_base_name = "test-kb" @@ -1296,6 +1313,16 @@ async def test_creates_knowledge_source_when_not_found(self) -> None: mock_index_client.create_knowledge_source.assert_awaited_once() mock_index_client.create_or_update_knowledge_base.assert_awaited_once() + knowledge_base = mock_index_client.create_or_update_knowledge_base.call_args.args[0] + sdk_api_key = knowledge_base.models[0].azure_open_ai_parameters.api_key + if api_key is None: + assert sdk_api_key is None + else: + assert type(sdk_api_key) is str + assert sdk_api_key == "aoai-key" + if isinstance(api_key, SecretString): + assert provider.azure_openai_api_key is api_key + assert "aoai-key" not in repr(provider.azure_openai_api_key) assert provider._knowledge_base_initialized is True async def test_uses_existing_knowledge_source(self) -> None: diff --git a/python/packages/azure-cosmos/agent_framework_azure_cosmos/_checkpoint_storage.py b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_checkpoint_storage.py index e441975600d..3ddd0e0c73f 100644 --- a/python/packages/azure-cosmos/agent_framework_azure_cosmos/_checkpoint_storage.py +++ b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_checkpoint_storage.py @@ -120,7 +120,7 @@ def __init__( endpoint: str | None = None, database_name: str | None = None, container_name: str | None = None, - credential: str | AzureCredentialTypes | None = None, + credential: str | SecretString | AzureCredentialTypes | None = None, cosmos_client: CosmosClient | None = None, container_client: ContainerProxy | None = None, env_file_path: str | None = None, @@ -186,7 +186,7 @@ def __init__( endpoint=endpoint, database_name=database_name, container_name=container_name, - key=credential if isinstance(credential, str) else None, + key=credential if isinstance(credential, (str, SecretString)) else None, env_file_path=env_file_path, env_file_encoding=env_file_encoding, ) @@ -194,6 +194,8 @@ def __init__( self.container_name = settings["container_name"] # type: ignore[assignment] if self._cosmos_client is None: + if isinstance(credential, SecretString): + credential = credential.get_secret_value() self._cosmos_client = CosmosClient( url=settings["endpoint"], # type: ignore[arg-type] credential=credential or settings["key"].get_secret_value(), # type: ignore[arg-type,union-attr] diff --git a/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py index 507fe228b05..9d9cb2968d5 100644 --- a/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py +++ b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py @@ -53,7 +53,7 @@ def __init__( endpoint: str | None = None, database_name: str | None = None, container_name: str | None = None, - credential: str | AzureCredentialTypes | None = None, + credential: str | SecretString | AzureCredentialTypes | None = None, cosmos_client: CosmosClient | None = None, container_client: ContainerProxy | None = None, env_file_path: str | None = None, @@ -114,13 +114,15 @@ def __init__( endpoint=endpoint, database_name=database_name, container_name=container_name, - key=credential if isinstance(credential, str) else None, + key=credential if isinstance(credential, (str, SecretString)) else None, env_file_path=env_file_path, env_file_encoding=env_file_encoding, ) self.database_name = settings["database_name"] # type: ignore[assignment] self.container_name = settings["container_name"] # type: ignore[assignment] if self._cosmos_client is None: + if isinstance(credential, SecretString): + credential = credential.get_secret_value() self._cosmos_client = CosmosClient( url=settings["endpoint"], # type: ignore[arg-type] credential=credential or settings["key"].get_secret_value(), # type: ignore[arg-type,union-attr] diff --git a/python/packages/azure-cosmos/tests/test_cosmos_checkpoint_storage.py b/python/packages/azure-cosmos/tests/test_cosmos_checkpoint_storage.py index 0fe47d5a5da..e4fd305171c 100644 --- a/python/packages/azure-cosmos/tests/test_cosmos_checkpoint_storage.py +++ b/python/packages/azure-cosmos/tests/test_cosmos_checkpoint_storage.py @@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from agent_framework._settings import SecretString from agent_framework._workflows._checkpoint import WorkflowCheckpoint from agent_framework._workflows._checkpoint_encoding import encode_checkpoint_value from agent_framework.exceptions import SettingNotFoundError, WorkflowCheckpointException @@ -129,17 +130,18 @@ async def test_init_missing_required_settings_raises(monkeypatch: pytest.MonkeyP CosmosCheckpointStorage() +@pytest.mark.parametrize( + "credential", [None, "key-123", SecretString("key-123"), MagicMock()], ids=["env", "str", "secret", "token"] +) async def test_init_constructs_client_with_credential( - monkeypatch: pytest.MonkeyPatch, mock_cosmos_client: MagicMock + monkeypatch: pytest.MonkeyPatch, + mock_cosmos_client: MagicMock, + credential: str | SecretString | MagicMock | None, ) -> None: - """Uses key-based auth when a key string is provided, otherwise falls back to Azure credential (RBAC).""" + """Unwrap explicit and environment keys while preserving Azure token credentials.""" mock_factory = MagicMock(return_value=mock_cosmos_client) monkeypatch.setattr(checkpoint_storage_module, "CosmosClient", mock_factory) - monkeypatch.delenv("AZURE_COSMOS_KEY", raising=False) - - # Simulate real-world pattern: use key if available, else RBAC credential - cosmos_key = os.getenv("AZURE_COSMOS_KEY") - credential: Any = cosmos_key if cosmos_key else MagicMock() # MagicMock simulates DefaultAzureCredential() + monkeypatch.setenv("AZURE_COSMOS_KEY", "env-key") CosmosCheckpointStorage( endpoint="https://account.documents.azure.com:443/", @@ -151,7 +153,11 @@ async def test_init_constructs_client_with_credential( mock_factory.assert_called_once() kwargs = mock_factory.call_args.kwargs assert kwargs["url"] == "https://account.documents.azure.com:443/" - assert kwargs["credential"] is credential + if isinstance(credential, MagicMock): + assert kwargs["credential"] is credential + else: + assert type(kwargs["credential"]) is str + assert kwargs["credential"] == ("env-key" if credential is None else "key-123") async def test_init_creates_database_and_container(mock_cosmos_client: MagicMock) -> None: diff --git a/python/packages/azure-cosmos/tests/test_cosmos_history_provider.py b/python/packages/azure-cosmos/tests/test_cosmos_history_provider.py index 7e581a7636e..e2c24016899 100644 --- a/python/packages/azure-cosmos/tests/test_cosmos_history_provider.py +++ b/python/packages/azure-cosmos/tests/test_cosmos_history_provider.py @@ -12,6 +12,7 @@ import pytest from agent_framework import AgentResponse, Message from agent_framework._sessions import AgentSession, SessionContext +from agent_framework._settings import SecretString from agent_framework.exceptions import SettingNotFoundError from azure.cosmos.aio import CosmosClient from azure.cosmos.exceptions import CosmosResourceNotFoundError @@ -104,15 +105,22 @@ def test_missing_required_settings_raises(self, monkeypatch: pytest.MonkeyPatch) with pytest.raises(SettingNotFoundError, match="database_name"): CosmosHistoryProvider() - def test_constructs_client_with_string_credential( - self, monkeypatch: pytest.MonkeyPatch, mock_cosmos_client: MagicMock + @pytest.mark.parametrize( + "credential", [None, "key-123", SecretString("key-123"), MagicMock()], ids=["env", "str", "secret", "token"] + ) + def test_constructs_client_with_credential( + self, + monkeypatch: pytest.MonkeyPatch, + mock_cosmos_client: MagicMock, + credential: str | SecretString | MagicMock | None, ) -> None: mock_factory = MagicMock(return_value=mock_cosmos_client) monkeypatch.setattr(history_provider_module, "CosmosClient", mock_factory) + monkeypatch.setenv("AZURE_COSMOS_KEY", "env-key") CosmosHistoryProvider( endpoint="https://account.documents.azure.com:443/", - credential="key-123", + credential=credential, database_name="db1", container_name="history", ) @@ -120,7 +128,11 @@ def test_constructs_client_with_string_credential( mock_factory.assert_called_once() kwargs = mock_factory.call_args.kwargs assert kwargs["url"] == "https://account.documents.azure.com:443/" - assert kwargs["credential"] == "key-123" + if isinstance(credential, MagicMock): + assert kwargs["credential"] is credential + else: + assert type(kwargs["credential"]) is str + assert kwargs["credential"] == ("env-key" if credential is None else "key-123") class TestCosmosHistoryProviderContainerConfig: diff --git a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py index c38b813fdae..d719027db0a 100644 --- a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py +++ b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py @@ -240,9 +240,9 @@ def __init__( *, region: str | None = None, model: str | None = None, - access_key: str | None = None, - secret_key: str | None = None, - session_token: str | None = None, + access_key: str | SecretString | None = None, + secret_key: str | SecretString | None = None, + session_token: str | SecretString | None = None, client: BaseClient | None = None, boto3_session: Boto3Session | None = None, additional_properties: dict[str, Any] | None = None, diff --git a/python/packages/bedrock/agent_framework_bedrock/_embedding_client.py b/python/packages/bedrock/agent_framework_bedrock/_embedding_client.py index cd71a9d0eb0..9d55d4a22f8 100644 --- a/python/packages/bedrock/agent_framework_bedrock/_embedding_client.py +++ b/python/packages/bedrock/agent_framework_bedrock/_embedding_client.py @@ -101,9 +101,9 @@ def __init__( *, region: str | None = None, model: str | None = None, - access_key: str | None = None, - secret_key: str | None = None, - session_token: str | None = None, + access_key: str | SecretString | None = None, + secret_key: str | SecretString | None = None, + session_token: str | SecretString | None = None, client: BaseClient | None = None, boto3_session: Boto3Session | None = None, additional_properties: dict[str, Any] | None = None, @@ -271,9 +271,9 @@ def __init__( *, region: str | None = None, model: str | None = None, - access_key: str | None = None, - secret_key: str | None = None, - session_token: str | None = None, + access_key: str | SecretString | None = None, + secret_key: str | SecretString | None = None, + session_token: str | SecretString | None = None, client: BaseClient | None = None, boto3_session: Boto3Session | None = None, otel_provider_name: str | None = None, diff --git a/python/packages/bedrock/tests/test_bedrock_client.py b/python/packages/bedrock/tests/test_bedrock_client.py index 6d339ae3c59..41e277d75f3 100644 --- a/python/packages/bedrock/tests/test_bedrock_client.py +++ b/python/packages/bedrock/tests/test_bedrock_client.py @@ -14,7 +14,7 @@ from boto3.session import Session as Boto3Session from botocore.client import BaseClient -from agent_framework_bedrock import BedrockChatClient +from agent_framework_bedrock import BedrockChatClient, BedrockEmbeddingClient from agent_framework_bedrock._chat_client import BedrockSettings from agent_framework_bedrock._feature_usage import FeatureIndex @@ -276,6 +276,38 @@ def client(self, service_name: str, *, region_name: str, config: Any) -> _StubBe ] +@pytest.mark.parametrize("secret_type", [str, SecretString], ids=["str", "secret"]) +@pytest.mark.parametrize( + ("client_type", "module"), + [(BedrockChatClient, "_chat_client"), (BedrockEmbeddingClient, "_embedding_client")], + ids=["chat", "embedding"], +) +def test_constructor_unwraps_session_credentials( + secret_type: type[str] | type[SecretString], + client_type: type[BedrockChatClient] | type[BedrockEmbeddingClient], + module: str, +) -> None: + session = MagicMock(region_name="eu-west-1") + session.client.return_value = _StubBedrockRuntime() + with patch(f"agent_framework_bedrock.{module}.Boto3Session", return_value=session) as session_cls: + client = client_type( + model="test-model", + region="eu-west-1", + access_key=secret_type("access"), + secret_key=secret_type("secret"), + session_token=secret_type("token"), + ) + + assert client.model == "test-model" + session_cls.assert_called_once_with( + region_name="eu-west-1", + aws_access_key_id="access", + aws_secret_access_key="secret", + aws_session_token="token", + ) + assert all(type(value) is str for value in session_cls.call_args.kwargs.values()) + + def test_create_session_uses_secret_values() -> None: """Bedrock session creation should unwrap configured secret values.""" settings: BedrockSettings = { @@ -294,6 +326,9 @@ def test_create_session_uses_secret_values() -> None: aws_secret_access_key="secret", aws_session_token="token", ) + assert all(type(value) is str for value in session_cls.call_args.kwargs.values()) + assert isinstance(settings["secret_key"], SecretString) + assert str(settings["secret_key"]) == "**********" def test_invoke_converse_requires_mapping_response() -> None: diff --git a/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py b/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py index 8121784e17c..f276395f722 100644 --- a/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py +++ b/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py @@ -15,6 +15,7 @@ ContextProvider, Message, ResponseStream, + SecretString, normalize_messages, ) from agent_framework._settings import load_settings @@ -75,7 +76,7 @@ def __init__( agent_identifier: str | None = None, client_id: str | None = None, tenant_id: str | None = None, - token: str | None = None, + token: str | SecretString | None = None, cloud: PowerPlatformCloud | None = None, agent_type: AgentType | None = None, custom_power_platform_cloud: str | None = None, @@ -206,7 +207,8 @@ def __init__( scopes=scopes, ) - client = CopilotClient(settings=settings, token=token) + resolved_token = token.get_secret_value() if isinstance(token, SecretString) else token + client = CopilotClient(settings=settings, token=resolved_token) self.client = client self.cloud = cloud diff --git a/python/packages/copilotstudio/tests/test_copilot_agent.py b/python/packages/copilotstudio/tests/test_copilot_agent.py index a661aeca26d..47dfe3a45bf 100644 --- a/python/packages/copilotstudio/tests/test_copilot_agent.py +++ b/python/packages/copilotstudio/tests/test_copilot_agent.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch import pytest -from agent_framework import AgentResponse, AgentResponseUpdate, AgentSession, Content, Message +from agent_framework import AgentResponse, AgentResponseUpdate, AgentSession, Content, Message, SecretString from agent_framework.exceptions import AgentException from microsoft_agents.copilotstudio.client import ConnectionSettings, CopilotClient @@ -100,6 +100,19 @@ def test_init_with_client(self, mock_copilot_client: MagicMock) -> None: assert agent.client == mock_copilot_client assert agent.id is not None + @pytest.mark.parametrize("token", ["fake-token", SecretString("fake-token")], ids=["str", "secret"]) + @patch("agent_framework_copilotstudio._agent.CopilotClient") + def test_init_unwraps_token_for_client(self, client_type: MagicMock, token: str | SecretString) -> None: + CopilotStudioAgent( + environment_id="env-id", + agent_identifier="agent-id", + token=token, + ) + + client_type.assert_called_once() + assert type(client_type.call_args.kwargs["token"]) is str + assert client_type.call_args.kwargs["token"] == "fake-token" + def test_init_applies_default_read_bufsize(self) -> None: """The internally built client raises aiohttp's per-line limit to avoid LineTooLong (issue #7257).""" agent = CopilotStudioAgent( diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index c6a4affe0eb..7339fcd9b74 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -38,6 +38,14 @@ agent_framework/ - Public deprecation behavior for a lazy export belongs in the owning module. The root package should delegate via the normal lazy export map instead of carrying one-off branches. +### Settings (`_settings.py`) + +- **`SecretString`** is a non-`str` wrapper: string conversion, representation, formatting, and concatenation + display a mask. String-only APIs such as `str.join()` and JSON encoding reject it unless callers explicitly + convert it. Use `get_secret_value()` when passing credentials to provider SDKs; `str(secret)` returns the mask. +- **`load_settings`** accepts plain string overrides for `SecretString` fields and wraps them, as it does for + environment and `.env` values. Existing `SecretString` overrides are preserved. + ### Agents (`_agents.py`) - **`SupportsAgentRun`** - Protocol defining the agent interface diff --git a/python/packages/core/agent_framework/_settings.py b/python/packages/core/agent_framework/_settings.py index 60b8e31353a..1629eb5ff96 100644 --- a/python/packages/core/agent_framework/_settings.py +++ b/python/packages/core/agent_framework/_settings.py @@ -49,37 +49,99 @@ class MySettings(TypedDict, total=False): SettingsT = TypeVar("SettingsT", default=dict[str, Any]) -class SecretString(str): - """A string subclass that masks its value in repr() to prevent accidental exposure. +class SecretString: + """A secret value that masks string conversion to prevent accidental exposure. - SecretString behaves exactly like a regular string in all operations, - but its repr() shows '**********' instead of the actual value. - This helps prevent secrets from being accidentally logged or displayed. + ``str()``, ``repr()``, formatting, and concatenation display '**********' + instead of the actual value. Equality, hashing, length, and truthiness + operate on the underlying value. - It also provides a ``get_secret_value()`` method for backward compatibility - with code that previously used ``pydantic.SecretStr``. + This is not a ``str`` subclass. Use ``get_secret_value()`` when passing a + credential to an SDK or another API that requires a real string. String-only + operations such as ``str.join()`` and JSON encoding reject the wrapper + rather than implicitly exposing its value. Explicitly extracted secrets + are ordinary strings and are no longer protected from accidental exposure. + + Args: + value: The secret string or an existing ``SecretString`` to wrap. Example: ```python api_key = SecretString("sk-secret-key") - print(api_key) # sk-secret-key (normal string behavior) + print(api_key) # ********** print(repr(api_key)) # SecretString('**********') - print(f"Key: {api_key}") # Key: sk-secret-key + print(f"Key: {api_key}") # Key: ********** + print("Bearer " + api_key) # Bearer ********** print(api_key.get_secret_value()) # sk-secret-key ``` """ + __slots__ = ("_value",) + + _value: str + + def __init__(self, value: str | SecretString) -> None: + if isinstance(value, SecretString): + value = value.get_secret_value() + if not isinstance(value, str): + raise TypeError("SecretString requires a string value.") + object.__setattr__(self, "_value", value) + + def __setattr__(self, name: str, value: object) -> None: + """Reject mutation after construction.""" + raise AttributeError("SecretString is immutable.") + + def __copy__(self) -> SecretString: + """Return this immutable instance for shallow copies.""" + return self + + def __deepcopy__(self, memo: dict[int, Any]) -> SecretString: + """Return this immutable instance for deep copies.""" + return self + + def __str__(self) -> str: + """Return a masked string to prevent secret exposure.""" + return "**********" + def __repr__(self) -> str: """Return a masked representation to prevent secret exposure.""" return "SecretString('**********')" - def get_secret_value(self) -> str: - """Return the underlying string value. + def __format__(self, format_spec: str) -> str: + """Apply string formatting to the masked value.""" + return format(str(self), format_spec) + + def __add__(self, other: str | SecretString) -> str: + """Concatenate strings without exposing secret values.""" + if isinstance(other, (str, SecretString)): + return str(self) + str(other) + return NotImplemented + + def __radd__(self, other: str | SecretString) -> str: + """Concatenate strings without exposing secret values.""" + if isinstance(other, (str, SecretString)): + return str(other) + str(self) + return NotImplemented + + def __eq__(self, other: object) -> bool: + """Compare the underlying values.""" + if isinstance(other, SecretString): + return self._value == other._value + if isinstance(other, str): + return self._value == other + return NotImplemented + + def __hash__(self) -> int: + """Hash the underlying value.""" + return hash(self._value) + + def __len__(self) -> int: + """Return the length of the underlying value.""" + return len(self._value) - Provided for backward compatibility with ``pydantic.SecretStr``. - Since SecretString *is* a str, this simply returns ``str(self)``. - """ - return str(self) + def get_secret_value(self) -> str: + """Explicitly return the unmasked string value for credential use.""" + return self._value def _coerce_value(value: str, target_type: type) -> Any: @@ -175,7 +237,7 @@ def _check_override_type(value: Any, field_type: type, field_name: str) -> None: if not isinstance(value, allowed): # Allow str for SecretString fields (will be coerced) - if isinstance(value, str) and any(isinstance(a, type) and issubclass(a, str) for a in allowed): + if isinstance(value, str) and any(issubclass(a, (str, SecretString)) for a in allowed): return # Allow int for float fields (standard numeric promotion) if isinstance(value, int) and float in allowed: @@ -256,7 +318,7 @@ def load_settings( override_value = overrides[field_name] _check_override_type(override_value, field_type, field_name) # Coerce plain str → SecretString if the annotation expects it - if isinstance(override_value, str) and not isinstance(override_value, SecretString): + if isinstance(override_value, str): with suppress(ValueError, TypeError): coerced = _coerce_value(override_value, field_type) if isinstance(coerced, SecretString): diff --git a/python/packages/core/tests/core/test_sessions.py b/python/packages/core/tests/core/test_sessions.py index 138e8a0fc18..9b789496a09 100644 --- a/python/packages/core/tests/core/test_sessions.py +++ b/python/packages/core/tests/core/test_sessions.py @@ -27,6 +27,7 @@ HistoryProvider, InMemoryHistoryProvider, Message, + SecretString, SessionContext, SessionStore, agent_middleware, @@ -884,6 +885,18 @@ async def test_set_then_get_returns_independent_copy(self) -> None: assert reread is not None assert reread.state["nested"]["values"] == ["original"] + async def test_set_and_get_preserves_immutable_secret_string(self) -> None: + store = SessionStore() + secret = SecretString("my-secret") + session = AgentSession(session_id="session-1") + session.state["secret"] = secret + + await store.set("session-1", session) + + stored = await store.get("session-1") + assert stored is not None + assert stored.state["secret"] is secret + async def test_set_stores_independent_snapshot(self) -> None: store = SessionStore() session = AgentSession(session_id="session-1") diff --git a/python/packages/core/tests/core/test_settings.py b/python/packages/core/tests/core/test_settings.py index 58d21a397c7..9070c31a6ed 100644 --- a/python/packages/core/tests/core/test_settings.py +++ b/python/packages/core/tests/core/test_settings.py @@ -2,8 +2,13 @@ """Tests for load_settings() function.""" +import json +import logging import os import tempfile +from collections.abc import Callable +from copy import copy, deepcopy +from pathlib import Path from typing import Any, Literal, TypedDict import pytest @@ -164,16 +169,169 @@ def test_secretstring_from_override(self) -> None: assert isinstance(settings["api_key"], SecretString) assert settings["api_key"] == "kwarg-secret" + def test_secretstring_from_wrapped_override(self) -> None: + secret = SecretString("my-secret") + + settings = load_settings(SecretSettings, env_prefix="SECRET_", api_key=secret) + resolved_api_key = settings["api_key"] + + assert resolved_api_key is secret + assert isinstance(resolved_api_key, SecretString) + assert resolved_api_key.get_secret_value() == "my-secret" + + def test_secretstring_from_dotenv(self, tmp_path: Path) -> None: + env_file = tmp_path / ".env" + env_file.write_text("SECRET_API_KEY=my-secret\n", encoding="utf-8") + + settings = load_settings(SecretSettings, env_prefix="SECRET_", env_file_path=str(env_file)) + + assert isinstance(settings["api_key"], SecretString) + assert settings["api_key"].get_secret_value() == "my-secret" + assert str(settings["api_key"]) == "**********" + def test_secretstring_masked_in_repr(self) -> None: s = SecretString("my-secret") assert "my-secret" not in repr(s) assert "**********" in repr(s) + @pytest.mark.parametrize("value", ["my-secret", "", "x", "secret\nwith\tcontrols"]) + @pytest.mark.parametrize( + "render", + [ + pytest.param(str, id="str"), + pytest.param(lambda secret: f"{secret}", id="f-string"), + pytest.param(lambda secret: f"{secret!s}", id="f-string-str"), + pytest.param(lambda secret, template="%s": template % secret, id="percent"), + pytest.param(lambda secret, template="%(key)s": template % {"key": secret}, id="percent-mapping"), + pytest.param("{}".format, id="format"), + pytest.param(lambda secret: "{key}".format_map({"key": secret}), id="format-map"), + ], + ) + def test_secretstring_masked_in_string_conversion(self, value: str, render: Callable[[SecretString], str]) -> None: + assert render(SecretString(value)) == "**********" + + @pytest.mark.parametrize("format_spec", ["", "s", ">20", "*^20", ".3", "20.3"]) + def test_secretstring_format_spec_applies_to_mask(self, format_spec: str) -> None: + secret = SecretString("my-secret") + + assert format(secret, format_spec) == format("**********", format_spec) + assert f"{secret:{format_spec}}" == format("**********", format_spec) + assert "{0:{1}}".format(secret, format_spec) == format("**********", format_spec) + + def test_secretstring_concatenation_masks_both_operands(self) -> None: + secret = SecretString("my-secret") + + assert "Bearer " + secret == "Bearer **********" + assert secret + " suffix" == "********** suffix" + assert secret + SecretString("another-secret") == "********************" + assert "prefix " + secret + " suffix" == "prefix ********** suffix" + + def test_secretstring_masked_in_containers(self) -> None: + secret = SecretString("my-secret") + + assert str({"key": secret}) == "{'key': SecretString('**********')}" + assert repr([secret]) == "[SecretString('**********')]" + assert str({secret: "value"}) == "{SecretString('**********'): 'value'}" + + def test_secretstring_masked_in_print(self, capsys: pytest.CaptureFixture[str]) -> None: + print(SecretString("my-secret")) # noqa: T201 + + assert capsys.readouterr().out == "**********\n" + + def test_secretstring_masked_in_logging(self, caplog: pytest.LogCaptureFixture) -> None: + secret = SecretString("my-secret") + logger = logging.getLogger(__name__) + format_message = "Key: {}".format + + with caplog.at_level(logging.INFO, logger=__name__): + logger.info("Key: %s", secret) + logger.info("Key: %(key)s", {"key": secret}) + logger.info(f"Key: {secret}") + logger.info(format_message(secret)) + logger.info("Key: " + secret) + logger.info(secret) + + assert caplog.messages == ["Key: **********"] * 5 + ["**********"] + + def test_secretstring_masked_in_exception(self) -> None: + secret = SecretString("my-secret") + + assert str(ValueError(secret)) == "**********" + assert str(ValueError(f"Invalid key: {secret}")) == "Invalid key: **********" + assert "my-secret" not in repr(ValueError(secret)) + + @pytest.mark.parametrize( + "consume", + [ + pytest.param(lambda secret: "".join([secret]), id="join"), + pytest.param(lambda secret: json.dumps(secret), id="json-value"), + pytest.param(lambda secret: json.dumps({"key": secret}), id="json-container"), + pytest.param(lambda secret: json.dumps({secret: "value"}), id="json-key"), + pytest.param(lambda secret: str.__str__(secret), id="str-base-method"), + pytest.param(lambda secret: secret[:], id="slice"), + pytest.param(lambda secret: secret + 1, id="add-non-string"), + pytest.param(lambda secret: 1 + secret, id="radd-non-string"), + ], + ) + def test_secretstring_rejects_implicit_raw_string_use(self, consume: Callable[[Any], Any]) -> None: + secret = SecretString("my-secret") + + assert not isinstance(secret, str) + with pytest.raises(TypeError) as exc_info: + consume(secret) + assert "my-secret" not in str(exc_info.value) + + def test_secretstring_json_with_explicit_string_conversion_masks(self) -> None: + assert json.dumps({"key": SecretString("my-secret")}, default=str) == '{"key": "**********"}' + + @pytest.mark.parametrize("value", ["my-secret", ""]) + def test_secretstring_value_semantics(self, value: str) -> None: + secret = SecretString(value) + + assert len(secret) == len(value) + assert bool(secret) == bool(value) + assert secret == SecretString(value) + assert secret == value + assert value == secret + assert secret != SecretString("another-secret") + assert secret != "another-secret" + assert secret != object() + assert hash(secret) == hash(value) + assert {secret, SecretString(value), value} == {value} + + def test_secretstring_is_immutable(self) -> None: + secret = SecretString("my-secret") + secrets = {secret} + + with pytest.raises(AttributeError, match="^SecretString is immutable\\.$"): + secret._value = "another-secret" + + assert secret.get_secret_value() == "my-secret" + assert secret in secrets + + def test_secretstring_copy_returns_same_immutable_instance(self) -> None: + secret = SecretString("my-secret") + + assert copy(secret) is secret + assert deepcopy(secret) is secret + + def test_secretstring_can_wrap_existing_secret(self) -> None: + secret = SecretString(SecretString("my-secret")) + + assert secret.get_secret_value() == "my-secret" + assert str(secret) == "**********" + + @pytest.mark.parametrize("value", [None, 123, b"my-secret"]) + def test_secretstring_rejects_non_strings(self, value: Any) -> None: + with pytest.raises(TypeError, match="^SecretString requires a string value\\.$"): + SecretString(value) + def test_get_secret_value_compat(self) -> None: s = SecretString("my-secret") assert s.get_secret_value() == "my-secret" - assert isinstance(s.get_secret_value(), str) + assert type(s.get_secret_value()) is str + assert f"Bearer {s.get_secret_value()}" == "Bearer my-secret" class TestTypeCoercion: @@ -261,6 +419,23 @@ def test_str_accepted_for_secretstring(self) -> None: assert isinstance(settings["api_key"], SecretString) assert settings["api_key"] == "plain-string" + def test_str_accepted_for_required_secretstring(self) -> None: + class RequiredSecretSettings(TypedDict): + api_key: SecretString + + settings = load_settings(RequiredSecretSettings, api_key="my-secret", required_fields=["api_key"]) + + assert isinstance(settings["api_key"], SecretString) + assert settings["api_key"].get_secret_value() == "my-secret" + + def test_invalid_secretstring_override_rejected(self) -> None: + with pytest.raises(ValueError, match="expected SecretString, got int"): + load_settings(SecretSettings, api_key=123) + + def test_secretstring_requires_explicit_unwrapping_for_str_field(self) -> None: + with pytest.raises(ValueError, match="expected str, got SecretString"): + load_settings(SimpleSettings, api_key=SecretString("my-secret")) + def test_parameterized_generic_union_arm_accepted(self) -> None: """A ``dict`` override is valid for ``dict[str, Any] | str | None``.""" diff --git a/python/packages/core/tests/workflow/test_state.py b/python/packages/core/tests/workflow/test_state.py index db9dd17ee50..e233eb976f2 100644 --- a/python/packages/core/tests/workflow/test_state.py +++ b/python/packages/core/tests/workflow/test_state.py @@ -4,6 +4,7 @@ import pytest +from agent_framework import SecretString from agent_framework._workflows._state import State @@ -15,6 +16,14 @@ def test_set_and_get(self) -> None: state.set("key", "value") assert state.get("key") == "value" + def test_set_and_get_secret_string(self) -> None: + state = State() + secret = SecretString("my-secret") + + state.set("key", secret) + + assert state.get("key") is secret + def test_set_does_not_alias_caller_value(self) -> None: state = State() value = {"history": ["step-1"]} diff --git a/python/packages/foundry/agent_framework_foundry/_embedding_client.py b/python/packages/foundry/agent_framework_foundry/_embedding_client.py index 779738e9150..c5254ad890a 100644 --- a/python/packages/foundry/agent_framework_foundry/_embedding_client.py +++ b/python/packages/foundry/agent_framework_foundry/_embedding_client.py @@ -14,6 +14,7 @@ Embedding, EmbeddingGenerationOptions, GeneratedEmbeddings, + SecretString, UsageDetails, load_settings, ) @@ -83,7 +84,7 @@ class FoundryEmbeddingSettings(TypedDict, total=False): """Foundry inference embedding settings.""" models_endpoint: str | None - models_api_key: str | None + models_api_key: SecretString | None embedding_model: str | None image_embedding_model: str | None @@ -123,7 +124,7 @@ def __init__( model: str | None = None, image_model: str | None = None, endpoint: str | None = None, - api_key: str | None = None, + api_key: str | SecretString | None = None, text_client: EmbeddingsClient | None = None, image_client: ImageEmbeddingsClient | None = None, credential: AzureKeyCredential | None = None, @@ -148,8 +149,8 @@ def __init__( self.image_model: str = settings.get("image_embedding_model") or self.model # type: ignore[assignment] resolved_endpoint = settings["models_endpoint"] # type: ignore[reportTypedDictNotRequiredAccess] - if credential is None and settings.get("models_api_key"): - credential = AzureKeyCredential(settings["models_api_key"]) # type: ignore[arg-type] + if credential is None and (models_api_key := settings.get("models_api_key")): + credential = AzureKeyCredential(models_api_key.get_secret_value()) if credential is None and text_client is None and image_client is None: raise ValueError("Either 'api_key', 'credential', or pre-configured client(s) must be provided.") @@ -381,7 +382,7 @@ def __init__( model: str | None = None, image_model: str | None = None, endpoint: str | None = None, - api_key: str | None = None, + api_key: str | SecretString | None = None, text_client: EmbeddingsClient | None = None, image_client: ImageEmbeddingsClient | None = None, credential: AzureKeyCredential | None = None, diff --git a/python/packages/foundry/tests/foundry/test_foundry_embedding_client.py b/python/packages/foundry/tests/foundry/test_foundry_embedding_client.py index 827f2ea4343..2eec8ea0672 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_embedding_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_embedding_client.py @@ -8,8 +8,9 @@ from unittest.mock import ANY, AsyncMock, MagicMock, patch import pytest -from agent_framework import Content +from agent_framework import Content, SecretString from agent_framework._telemetry import get_user_agent +from azure.core.credentials import AzureKeyCredential from agent_framework_foundry import ( FoundryEmbeddingClient, @@ -279,6 +280,24 @@ async def test_text_embeddings(self, client: FoundryEmbeddingClient[Any], mock_t assert len(result) == 1 assert result[0].vector == [0.1, 0.2, 0.3] + def test_accepts_secret_string_api_key(self) -> None: + with ( + patch("agent_framework_foundry._embedding_client.EmbeddingsClient") as text_client_type, + patch("agent_framework_foundry._embedding_client.ImageEmbeddingsClient") as image_client_type, + ): + FoundryEmbeddingClient( + model="test-model", + endpoint="https://test.inference.ai.azure.com", + api_key=SecretString("test-key"), + ) + + text_credential = text_client_type.call_args.kwargs["credential"] + image_credential = image_client_type.call_args.kwargs["credential"] + assert isinstance(text_credential, AzureKeyCredential) + assert text_credential is image_credential + assert type(text_credential.key) is str + assert text_credential.key == "test-key" + async def test_otel_provider_name_default(self) -> None: """Default OTEL provider name is azure.ai.inference.""" assert FoundryEmbeddingClient.OTEL_PROVIDER_NAME == "azure.ai.inference" diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index 17b2feafdb6..a6c6796b418 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -345,7 +345,7 @@ class RawGeminiChatClient( def __init__( self, *, - api_key: str | None = None, + api_key: str | SecretString | None = None, model: str | None = None, vertexai: bool | None = None, project: str | None = None, @@ -1390,7 +1390,7 @@ class GeminiChatClient( def __init__( self, *, - api_key: str | None = None, + api_key: str | SecretString | None = None, model: str | None = None, vertexai: bool | None = None, project: str | None = None, diff --git a/python/packages/gemini/tests/test_gemini_client.py b/python/packages/gemini/tests/test_gemini_client.py index 74b1687e1d3..f9f3f774d3d 100644 --- a/python/packages/gemini/tests/test_gemini_client.py +++ b/python/packages/gemini/tests/test_gemini_client.py @@ -12,6 +12,7 @@ import pytest from agent_framework import Agent, Content, FunctionTool, Message +from agent_framework._settings import SecretString from agent_framework.exceptions import ( ChatClientException, ChatClientInvalidAuthException, @@ -227,7 +228,10 @@ def test_client_created_from_api_key(monkeypatch: pytest.MonkeyPatch) -> None: assert client.model == "gemini-2.5-flash" -def test_client_created_from_google_api_key_env(monkeypatch: pytest.MonkeyPatch) -> None: +@pytest.mark.parametrize("api_key", [None, "explicit-key", SecretString("explicit-key")], ids=["env", "str", "secret"]) +def test_client_created_from_google_api_key_env( + monkeypatch: pytest.MonkeyPatch, api_key: str | SecretString | None +) -> None: """Initialises successfully when the SDK-standard Google API key environment variable is set.""" monkeypatch.delenv("GEMINI_API_KEY", raising=False) monkeypatch.delenv("GEMINI_MODEL", raising=False) @@ -243,9 +247,10 @@ def test_client_created_from_google_api_key_env(monkeypatch: pytest.MonkeyPatch) with patch("agent_framework_gemini._chat_client.genai.Client") as client_factory: client_factory.return_value = mock_client - client = GeminiChatClient() + client = GeminiChatClient(api_key=api_key) - assert client_factory.call_args.kwargs["api_key"] == "test-key-123" + assert type(client_factory.call_args.kwargs["api_key"]) is str + assert client_factory.call_args.kwargs["api_key"] == ("test-key-123" if api_key is None else "explicit-key") assert "vertexai" not in client_factory.call_args.kwargs assert client.model == "gemini-2.5-flash-lite" assert client.service_url() == "https://generativelanguage.googleapis.com" @@ -311,7 +316,10 @@ def test_missing_api_key_raises_when_no_client_injected(monkeypatch: pytest.Monk GeminiChatClient(model="gemini-2.5-flash") -def test_vertex_ai_express_mode_uses_api_key(monkeypatch: pytest.MonkeyPatch) -> None: +@pytest.mark.parametrize("api_key", [None, "explicit-key", SecretString("explicit-key")], ids=["env", "str", "secret"]) +def test_vertex_ai_express_mode_uses_api_key( + monkeypatch: pytest.MonkeyPatch, api_key: str | SecretString | None +) -> None: """Passes the API key in Vertex AI express mode when no project/location pair is configured.""" monkeypatch.delenv("GEMINI_API_KEY", raising=False) monkeypatch.delenv("GEMINI_MODEL", raising=False) @@ -325,10 +333,11 @@ def test_vertex_ai_express_mode_uses_api_key(monkeypatch: pytest.MonkeyPatch) -> mock_client._api_client._http_options.base_url = "https://aiplatform.googleapis.com/" with patch("agent_framework_gemini._chat_client.genai.Client", return_value=mock_client) as client_factory: - client = GeminiChatClient(model="gemini-2.5-flash-lite") + client = GeminiChatClient(model="gemini-2.5-flash-lite", api_key=api_key) assert client_factory.call_args.kwargs["vertexai"] is True - assert client_factory.call_args.kwargs["api_key"] == "test-key-123" + assert type(client_factory.call_args.kwargs["api_key"]) is str + assert client_factory.call_args.kwargs["api_key"] == ("test-key-123" if api_key is None else "explicit-key") assert "project" not in client_factory.call_args.kwargs assert "location" not in client_factory.call_args.kwargs assert client.service_url() == "https://aiplatform.googleapis.com" diff --git a/python/packages/mem0/agent_framework_mem0/_context_provider.py b/python/packages/mem0/agent_framework_mem0/_context_provider.py index 20bba5ad9e1..d89a110733d 100644 --- a/python/packages/mem0/agent_framework_mem0/_context_provider.py +++ b/python/packages/mem0/agent_framework_mem0/_context_provider.py @@ -15,7 +15,7 @@ from contextlib import AbstractAsyncContextManager from typing import TYPE_CHECKING, Any, ClassVar, TypeAlias, TypedDict -from agent_framework import Message +from agent_framework import Message, SecretString from agent_framework._sessions import AgentSession, ContextProvider, SessionContext from agent_framework._telemetry import mark_feature_used from mem0 import AsyncMemory, AsyncMemoryClient @@ -68,7 +68,7 @@ def __init__( self, source_id: str = DEFAULT_SOURCE_ID, mem0_client: AsyncMemory | AsyncMemoryClient | None = None, - api_key: str | None = None, + api_key: str | SecretString | None = None, application_id: str | None = None, agent_id: str | None = None, user_id: str | None = None, @@ -105,7 +105,8 @@ def __init__( super().__init__(source_id) should_close_client = False if mem0_client is None: - mem0_client = AsyncMemoryClient(api_key=api_key) + resolved_api_key = api_key.get_secret_value() if isinstance(api_key, SecretString) else api_key + mem0_client = AsyncMemoryClient(api_key=resolved_api_key) should_close_client = True self.api_key = api_key diff --git a/python/packages/mem0/tests/test_mem0_context_provider.py b/python/packages/mem0/tests/test_mem0_context_provider.py index bde518c56d1..9318cbf3103 100644 --- a/python/packages/mem0/tests/test_mem0_context_provider.py +++ b/python/packages/mem0/tests/test_mem0_context_provider.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from agent_framework import AgentResponse, Message +from agent_framework import AgentResponse, Message, SecretString from agent_framework._sessions import AgentSession, SessionContext from agent_framework_mem0._context_provider import Mem0ContextProvider @@ -82,14 +82,16 @@ def test_init_default_context_prompt(self, mock_mem0_client: AsyncMock) -> None: provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1") assert provider.context_prompt == Mem0ContextProvider.DEFAULT_CONTEXT_PROMPT - def test_init_auto_creates_client_when_none(self) -> None: + @pytest.mark.parametrize("api_key", ["test-key", SecretString("test-key")], ids=["str", "secret"]) + def test_init_auto_creates_client_when_none(self, api_key: str | SecretString) -> None: """When no client is provided, a default AsyncMemoryClient is created and flagged for closing.""" with ( patch("mem0.client.main.AsyncMemoryClient.__init__", return_value=None) as mock_init, patch("mem0.client.main.AsyncMemoryClient._validate_api_key", return_value=None), ): - provider = Mem0ContextProvider(source_id="mem0", api_key="test-key", user_id="u1") + provider = Mem0ContextProvider(source_id="mem0", api_key=api_key, user_id="u1") mock_init.assert_called_once_with(api_key="test-key") + assert type(mock_init.call_args.kwargs["api_key"]) is str assert provider._should_close_client is True def test_provided_client_not_flagged_for_close(self, mock_mem0_client: AsyncMock) -> None: diff --git a/python/packages/mistral/tests/mistral/test_mistral_chat_client.py b/python/packages/mistral/tests/mistral/test_mistral_chat_client.py index 8d2888bf041..c1c86042a6f 100644 --- a/python/packages/mistral/tests/mistral/test_mistral_chat_client.py +++ b/python/packages/mistral/tests/mistral/test_mistral_chat_client.py @@ -9,6 +9,7 @@ import httpx import pytest from agent_framework import Agent, ChatResponse, Content, Message, tool +from agent_framework._settings import SecretString from agent_framework.exceptions import ( ChatClientException, ChatClientInvalidAuthException, @@ -134,11 +135,16 @@ def test_mistral_chat_construction_env(monkeypatch: pytest.MonkeyPatch) -> None: assert client.model == "mistral-large-latest" -def test_mistral_chat_construction_with_params() -> None: - client = MistralChatClient(model="mistral-large-latest", api_key="test-key") +@pytest.mark.parametrize("api_key", ["test-key", SecretString("test-key")], ids=["str", "secret"]) +def test_mistral_chat_construction_with_params(api_key: str | SecretString) -> None: + client = MistralChatClient(model="mistral-large-latest", api_key=api_key) assert client.model == "mistral-large-latest" assert isinstance(client.client, Mistral) assert client.client.sdk_configuration.timeout_ms == 60_000 + security = client.client.sdk_configuration.security + assert security is not None and not callable(security) + assert type(security.api_key) is str + assert security.api_key == "test-key" def test_mistral_chat_construction_with_server_url() -> None: diff --git a/python/packages/mistral/tests/mistral/test_mistral_embedding_client.py b/python/packages/mistral/tests/mistral/test_mistral_embedding_client.py index c08a033b0c9..eb6851e803b 100644 --- a/python/packages/mistral/tests/mistral/test_mistral_embedding_client.py +++ b/python/packages/mistral/tests/mistral/test_mistral_embedding_client.py @@ -9,6 +9,7 @@ import httpx import pytest from agent_framework import Embedding, GeneratedEmbeddings +from agent_framework._settings import SecretString from agent_framework.exceptions import ( IntegrationException, IntegrationInvalidAuthException, @@ -73,12 +74,17 @@ def test_mistral_embedding_construction(monkeypatch: pytest.MonkeyPatch) -> None assert client.model == "mistral-embed" -def test_mistral_embedding_construction_with_params() -> None: +@pytest.mark.parametrize("api_key", ["test-key", SecretString("test-key")], ids=["str", "secret"]) +def test_mistral_embedding_construction_with_params(api_key: str | SecretString) -> None: """Test construction with explicit parameters.""" - client = MistralEmbeddingClient(model="mistral-embed", api_key="test-key") + client = MistralEmbeddingClient(model="mistral-embed", api_key=api_key) assert client.model == "mistral-embed" assert isinstance(client.client, Mistral) assert client.client.sdk_configuration.timeout_ms == 60_000 + security = client.client.sdk_configuration.security + assert security is not None and not callable(security) + assert type(security.api_key) is str + assert security.api_key == "test-key" def test_mistral_embedding_construction_with_server_url() -> None: diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index a9120e72ca0..0cc2bc3e34c 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -3609,7 +3609,7 @@ def __init__( self, model: str | None = None, *, - api_key: str | Callable[[], str | Awaitable[str]] | None = None, + api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, org_id: str | None = None, base_url: str | None = None, default_headers: Mapping[str, str] | None = None, @@ -3656,7 +3656,7 @@ def __init__( azure_endpoint: str | None = None, credential: AzureCredentialTypes | AzureTokenProvider | None = None, api_version: str | None = None, - api_key: str | Callable[[], str | Awaitable[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, @@ -3705,7 +3705,7 @@ def __init__( self, model: str | None = None, *, - api_key: str | Callable[[], str | Awaitable[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, 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 0682753ed39..700825d67f8 100644 --- a/python/packages/openai/agent_framework_openai/_chat_completion_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -1362,7 +1362,7 @@ def __init__( self, model: str | None = None, *, - api_key: str | Callable[[], str | Awaitable[str]] | None = None, + api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, org_id: str | None = None, base_url: str | None = None, default_headers: Mapping[str, str] | None = None, @@ -1409,7 +1409,7 @@ def __init__( azure_endpoint: str | None = None, credential: AzureCredentialTypes | AzureTokenProvider | None = None, api_version: str | None = None, - api_key: str | Callable[[], str | Awaitable[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, @@ -1458,7 +1458,7 @@ def __init__( self, model: str | None = None, *, - api_key: str | Callable[[], str | Awaitable[str]] | None = None, + api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, credential: AzureCredentialTypes | AzureTokenProvider | None = None, org_id: str | None = None, default_headers: Mapping[str, str] | None = None, diff --git a/python/packages/openai/agent_framework_openai/_embedding_client.py b/python/packages/openai/agent_framework_openai/_embedding_client.py index d2b84d1f420..c3ab22bdd13 100644 --- a/python/packages/openai/agent_framework_openai/_embedding_client.py +++ b/python/packages/openai/agent_framework_openai/_embedding_client.py @@ -331,7 +331,7 @@ def __init__( self, *, model: str | None = None, - api_key: str | Callable[[], str | Awaitable[str]] | None = None, + api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, org_id: str | None = None, default_headers: Mapping[str, str] | None = None, async_client: AsyncOpenAI | None = None, @@ -368,7 +368,7 @@ def __init__( azure_endpoint: str | None = None, credential: AzureCredentialTypes | AzureTokenProvider | None = None, api_version: str | None = None, - api_key: str | Callable[[], str | Awaitable[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, @@ -407,7 +407,7 @@ def __init__( self, *, model: str | None = None, - api_key: str | Callable[[], str | Awaitable[str]] | None = None, + api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, credential: AzureCredentialTypes | AzureTokenProvider | None = None, org_id: str | None = None, default_headers: Mapping[str, str] | None = None, 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 629c47a79ac..ba57b8ee754 100644 --- a/python/packages/openai/tests/openai/test_openai_embedding_client.py +++ b/python/packages/openai/tests/openai/test_openai_embedding_client.py @@ -9,7 +9,7 @@ import agent_framework._telemetry as telemetry import pytest -from agent_framework import SupportsGetEmbeddings +from agent_framework import SecretString, SupportsGetEmbeddings from agent_framework._telemetry import get_feature_token from agent_framework.exceptions import SettingNotFoundError from openai.types import CreateEmbeddingResponse @@ -52,6 +52,17 @@ def test_openai_construction_with_explicit_params() -> None: assert isinstance(client, SupportsGetEmbeddings) +def test_public_openai_embedding_client_accepts_secret_string() -> None: + client = OpenAIEmbeddingClient( + model="text-embedding-3-small", + api_key=SecretString("test-key"), + ) + + assert client.client is not None + assert type(client.client.api_key) is str + assert client.client.api_key == "test-key" + + def test_raw_openai_embedding_client_init_uses_explicit_parameters() -> None: signature = inspect.signature(RawOpenAIEmbeddingClient.__init__) diff --git a/python/packages/openai/tests/openai/test_openai_shared.py b/python/packages/openai/tests/openai/test_openai_shared.py index f1036949b39..7f62cc491a0 100644 --- a/python/packages/openai/tests/openai/test_openai_shared.py +++ b/python/packages/openai/tests/openai/test_openai_shared.py @@ -10,16 +10,19 @@ import agent_framework._telemetry as telemetry import pytest from agent_framework import AGENT_FRAMEWORK_USER_AGENT +from agent_framework._settings import SecretString from agent_framework._telemetry import FeatureIndex as CoreFeatureIndex from agent_framework._telemetry import mark_feature_used from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential +from openai import AsyncAzureOpenAI from agent_framework_openai._feature_usage import create_feature_usage_http_client from agent_framework_openai._shared import ( AZURE_OPENAI_TOKEN_SCOPE, _ensure_async_token_provider, _resolve_azure_credential_to_token_provider, + load_openai_service_settings, ) @@ -44,6 +47,35 @@ def get_token(self, *scopes: str, **kwargs: object): raise NotImplementedError +@pytest.mark.usefixtures("openai_unit_test_env") +@pytest.mark.parametrize("api_key", ["test-secret-key", SecretString("test-secret-key")], ids=["str", "secret"]) +@pytest.mark.parametrize("route", ["openai", "azure"]) +async def test_service_settings_unwrap_api_key_at_sdk_boundary(api_key: str | SecretString, route: str) -> None: + settings, sdk_client, use_azure = load_openai_service_settings( + model="test-model", + api_key=api_key, + credential=None, + org_id=None, + base_url=None, + endpoint="https://test.openai.azure.com" if route != "openai" else None, + api_version=None, + default_azure_api_version="2024-12-01-preview", + env_file_path=None, + env_file_encoding=None, + ) + try: + assert use_azure is (route != "openai") + assert isinstance(sdk_client, AsyncAzureOpenAI) is (route == "azure") + assert type(sdk_client.api_key) is str + assert sdk_client.api_key == "test-secret-key" + assert isinstance(settings["api_key"], SecretString) + assert settings["api_key"].get_secret_value() == "test-secret-key" + assert "test-secret-key" not in str(settings["api_key"]) + assert "test-secret-key" not in repr(settings) + finally: + await sdk_client.close() + + def test_resolve_azure_async_credential_wraps_provider() -> None: credential = _AsyncTokenCredentialStub() token_provider = MagicMock() From 114492aff3b03c81ee0423d09029c08f0cfcdf75 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Tue, 8 Sep 2026 07:09:01 +0000 Subject: [PATCH 77/85] Python: Add portable vector filters and in-memory store (#8115) * Python: Add portable vector filters and in-memory store Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e1edc1-384b-4163-b8bd-f659a510ad1f * Python: Refine vector filter safety contracts Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e1edc1-384b-4163-b8bd-f659a510ad1f * Python: Harden filter schema validation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e1edc1-384b-4163-b8bd-f659a510ad1f * Python: Add explicit null omission for vector filters Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e1edc1-384b-4163-b8bd-f659a510ad1f * Python: Preserve nested filter boolean distinctions Compare supported container values recursively so equality and membership filters do not equate nested booleans with numbers. Preserve native container and numeric equality semantics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e1edc1-384b-4163-b8bd-f659a510ad1f * Python: Validate dense vector dimensions at boundaries Check final dense vector lengths before connector conversion and writes, and validate query dimensions before search dispatch. Preserve binary and provider-native handling and document the bounded length-only contract. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e1edc1-384b-4163-b8bd-f659a510ad1f * Python: Harden vector scoring and filter validation Stabilize cosine calculations and reject non-finite or unsupported scores. Bound filter inspection and snapshot work, reject hidden parameters and malformed string operands, and isolate supplied parameter values. Validate generated-key flags and normalize binary metadata. Document retained normalized Hamming semantics and the stored-record normalization boundary. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e1edc1-384b-4163-b8bd-f659a510ad1f * Python: Delegate search thresholds to connectors Remove core threshold direction validation and result post-filtering. Connectors own scoring, filter execution, thresholds, and paging; in-memory applies thresholds locally using cosine distance for DEFAULT. Document the execution boundary and preserve portable request validation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e1edc1-384b-4163-b8bd-f659a510ad1f --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e1edc1-384b-4163-b8bd-f659a510ad1f --- .github/CODEOWNERS | 2 + .../vector-stores-and-embeddings/README.md | 167 ++- python/packages/core/AGENTS.md | 39 +- .../packages/core/agent_framework/__init__.py | 20 + .../core/agent_framework/__init__.pyi | 11 + .../core/agent_framework/_in_memory.py | 604 ++++++++ .../core/agent_framework/_vector_filters.py | 874 +++++++++++ .../packages/core/agent_framework/_vectors.py | 702 +++++---- .../core/tests/core/test_in_memory.py | 811 ++++++++++ .../packages/core/tests/core/test_vectors.py | 1323 +++++++++++++++-- .../samples/02-agents/vector_stores/README.md | 9 +- .../vector_stores/in_memory_filters.py | 73 + .../vector_stores/in_memory_search_tool.py | 189 +++ python/samples/AGENTS.md | 2 +- 14 files changed, 4406 insertions(+), 420 deletions(-) create mode 100644 python/packages/core/agent_framework/_in_memory.py create mode 100644 python/packages/core/agent_framework/_vector_filters.py create mode 100644 python/packages/core/tests/core/test_in_memory.py create mode 100644 python/samples/02-agents/vector_stores/in_memory_filters.py create mode 100644 python/samples/02-agents/vector_stores/in_memory_search_tool.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 540ff46dd8d..0803547f9ba 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -61,6 +61,8 @@ /python/packages/claude/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 /python/packages/copilotstudio/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 /python/packages/core/ @chetantoshniwal @eavanvalkenburg @moonbox3 @TaoChenOSU @jpalvarezl @giles17 +/python/packages/core/agent_framework/_in_memory.py @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl @peibekwe @baywet @rogerbarreto @SergeyMenshykh +/python/packages/core/agent_framework/_vector_filters.py @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl @peibekwe @baywet @rogerbarreto @SergeyMenshykh /python/packages/core/agent_framework/_vectors.py @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl @peibekwe @baywet @rogerbarreto @SergeyMenshykh /python/packages/core/agent_framework/_workflows/ @chetantoshniwal @eavanvalkenburg @moonbox3 @TaoChenOSU @jpalvarezl /python/packages/core/agent_framework/_harness/ @chetantoshniwal @westey-m @eavanvalkenburg @moonbox3 diff --git a/docs/features/vector-stores-and-embeddings/README.md b/docs/features/vector-stores-and-embeddings/README.md index 15e058fd58a..ecdac79117a 100644 --- a/docs/features/vector-stores-and-embeddings/README.md +++ b/docs/features/vector-stores-and-embeddings/README.md @@ -36,17 +36,40 @@ This feature ports the vector store abstractions, embedding generator abstractio - `SupportsVectorUpsert` / `SupportsVectorSearch` — Protocols for duck-typing (follows `Supports` naming convention) - `BaseVectorCollection` / `BaseVectorSearch` — ABC base classes for implementations - `BaseVectorStore` — ABC base class for store operations (factory for collections, no protocol needed) -- **TypeVar naming convention**: `ModelT`, `KeyT`, `FilterT` (suffix T, per AF standard) +- **TypeVar naming convention**: `ModelT`, `KeyT` (suffix T, per AF standard) - **Support Pydantic for user-facing data models** — the `@vectorstoremodel` decorator and `VectorStoreCollectionDefinition` should work with Pydantic models, dataclasses, plain classes, and dicts - **Remove SK-specific dependencies** — no `KernelBaseModel`, `KernelFunction`, `KernelParameterMetadata`, `kernel_function`, `PromptExecutionSettings` - **Embedding types in `_types.py`**, embedding protocol/base class in `_clients.py` -- **All vector store specific types, enums, protocols, base classes** in `_vectors.py` +- **Portable filters** are data-only operation inputs in `_vector_filters.py`; no Python source or AST translation +- **Dependency-free local storage** is isolated in `_in_memory.py` +- **Vector store definitions, protocols, and base classes** remain in `_vectors.py` - **Error handling** uses AF's exception hierarchy (e.g., `IntegrationException` variants) +### Vector Filter Representation + +The original Phase 3 design accepted callable or string lambdas, recovered +their source with `inspect`, parsed the source into an AST, and delegated +translation to each connector. Phase 4 replaces that experimental model before +connector implementations depend on it. + +Options considered: + +- **Lambda source and connector-specific AST translation** — concise authoring, + but brittle across Python execution contexts, vulnerable to semantic drift, + and carries security concerns when evaluated locally. +- **A closed class hierarchy with one type per operation** — strongly typed, + but every provider-specific capability would require another core type. +- **A small data-only tree with namespaced provider extensions** — chosen. + `Filter` represents a field, operator, and value; `FilterGroup` provides + explicit AND, OR, and NOT composition; `Param` marks model-set values when + creating a search tool. Common operators have shared semantics, while + namespaced operators let connectors add structured provider capabilities + without accepting raw query source. + ### Package Structure - **Embedding types** (`Embedding`, `GeneratedEmbeddings`, `EmbeddingGenerationOptions`) in `agent_framework/_types.py` - **Embedding protocol + base class** (`SupportsGetEmbeddings`, `BaseEmbeddingClient`) in `agent_framework/_clients.py` -- **All vector store specific code** in a new `agent_framework/_vectors.py` module — this includes: +- **Vector store abstractions** in `agent_framework/_vectors.py` — this includes: - String literal aliases: `FieldTypes`, `IndexKind`, `DistanceFunction` - `VectorStoreField`, `VectorStoreCollectionDefinition` - `SearchResponse`, `SearchResults`, and explicit CRUD/search keyword arguments @@ -57,8 +80,8 @@ This feature ports the vector store abstractions, embedding generator abstractio - **OpenAI embeddings** in `agent_framework/openai/` (built into core, like OpenAI chat) - **Azure OpenAI embeddings** in `agent_framework/azure/` (built into core, follows `AzureOpenAIChatClient` pattern) - **Each vector store connector** in its own AF package under `packages/` -- **In-memory store** in core (no external deps) -- **TextSearch and its implementations** (Brave, Google) — last phase, separate work +- **Portable filters** (`Filter`, `FilterGroup`, `Param`) in `agent_framework/_vector_filters.py` +- **In-memory store** in `agent_framework/_in_memory.py` ## Naming: SK → AF @@ -190,12 +213,14 @@ This feature ports the vector store abstractions, embedding generator abstractio #### 3.1 — Vector store literal aliases and field types in `_vectors.py` - `FieldTypes`: `Literal["key", "vector", "data"]` -- `IndexKind`: literal alias covering `hnsw`, `flat`, `ivf_flat`, `disk_ann`, `quantized_flat`, `dynamic`, and `default` -- `DistanceFunction`: literal alias covering the supported similarity and distance functions +- `IndexKind`: common literal values for IDE guidance plus open provider-defined strings +- `DistanceFunction`: common literal values, including negative dot product, plus open provider-defined strings - `SearchType`: `Literal["vector", "keyword_hybrid"]` - `VectorStoreField` plain class (not Pydantic) + - Key fields can opt into store-generated keys + - Provider annotations are copied on construction and carry mutable connector-specific field configuration - `VectorStoreCollectionDefinition` class (not Pydantic internally, but supports Pydantic models as input) -- `SearchResponse` generic class +- `SearchResponse` generic `TypedDict` - `SearchResults` generic result container - Explicit keyword arguments on `get()` and `search()` instead of options classes - `DISTANCE_FUNCTION_DIRECTION_HELPER` dict @@ -223,7 +248,15 @@ This feature ports the vector store abstractions, embedding generator abstractio - Batch-oriented `upsert`, `get`, and `delete` - `upsert()` generates vector values by default and requires an embedding generator for every vector field; pass `generate_vectors=False` to preserve supplied vector values + - `generate_vectors` can also take a list or tuple of selected vector field names, allowing one model to combine + locally generated, precomputed, and provider-vectorized values + - After optional generation, check materialized dense sequence lengths against each field's `dimensions` for the + entire batch before connector conversion or writes. A mismatch raises `ValueError` with the zero-based record + index, logical field name, and expected/actual lengths; this rejection performs no writes + - Batch upsert does not promise atomicity; stable application keys make retries safer, while store-generated keys + may produce duplicates after a partial failure - CRUD `get()` excludes vectors by default; pass `include_vectors=True` when stored embeddings are needed + - CRUD `get()` accepts either keys or a portable filter with paging and ordering - `ensure_collection_exists`, `collection_exists`, `ensure_collection_deleted` - Async context manager support - `BaseVectorStore` — base for stores @@ -234,8 +267,13 @@ This feature ports the vector store abstractions, embedding generator abstractio - `BaseVectorSearch` — base for vector search - Single `search(search_type=...)` method with `search_type: Literal["vector", "keyword_hybrid"]` parameter — no enum, just a literal - `_inner_search` abstract method for implementations - - Filter building with lambda parser (AST-based) + - Portable `Filter` and `FilterGroup` trees passed unchanged to connector implementations + - Core validates portable request structure and deserializes returned records, but does not compute scores, + interpret score thresholds, or re-filter connector results. Connectors own execution and paging - Vector generation from values using embedding generator + - Check supplied or locally generated dense query length against the selected field's `dimensions` before + connector dispatch, including empty collections. In-memory search also checks array-like queries after its + numeric normalization; existing query/stored length checks remain in scoring #### 3.6 — Protocols for type checking - `SupportsVectorUpsert` — Protocol for upsert/get/delete operations @@ -251,9 +289,10 @@ This feature ports the vector store abstractions, embedding generator abstractio #### 3.8 — `create_vector_search_tool` - Standalone factory that creates an AF `FunctionTool` from any `SupportsVectorSearch` implementation - Wraps the single `search()` method, passing `search_type` parameter -- Accepts: `name`, `description`, `approval_mode`, `search_type`, `parameters`, `top`, `skip`, `filter`, `filter_mapper`, `result_mapper` -- Defaults to `query`; a custom Pydantic model or JSON schema can expose `top`, `skip`, and additional filter fields -- Custom schemas must require a string `query`; exposed `top` and `skip` fields must declare finite maximum values +- Accepts: `name`, `description`, `approval_mode`, `search_type`, `top`, `skip`, `filter`, `result_mapper` +- Defaults to a required string `query` +- Discovers `Param` values in filters and paging options, generating a closed JSON Schema without Pydantic +- Validates model-set values against native Python types and inline constraints before resolving the filter - The tool vectorizes the query, searches, and maps results to text or multimodal `Content` - Can also be a standalone factory function in `_vectors.py` @@ -265,19 +304,84 @@ This feature ports the vector store abstractions, embedding generator abstractio --- -### Phase 4: In-Memory Vector Store -**Goal:** Provide a zero-dependency vector store for testing and development. -**Mergeable:** Yes — first usable vector store. - -#### 4.1 — Port `InMemoryCollection` and `InMemoryStore` into core -- Place in `agent_framework/_vectors.py` (alongside the abstractions) -- Supports vector search (cosine similarity, etc.) -- No external dependencies - -#### 4.2 — Port FAISS extension (optional, can be separate package) -- Extends InMemory with FAISS indexing - -#### 4.3 — Tests and sample code +### Phase 4: Portable Filters and In-Memory Vector Store +**Goal:** Provide safe cross-store filters and a zero-dependency vector store for testing and development. +**Mergeable:** Yes — the filter contract and in-memory implementation can be reviewed independently. + +#### 4.1 — Replace source filters with portable data +- `Filter(field_name, operator, value)` for leaf conditions +- `FilterGroup(operator, filters)` for explicit AND, OR, and NOT composition +- Common operators plus namespaced provider extensions +- No callable inspection, source strings, AST parsing, `eval`, `exec`, or `compile` + +#### 4.1.1 — Connector extensibility aligned with Microsoft.Extensions.VectorData +- Index kinds and distance functions provide common literal hints but remain open to provider-defined strings +- `VectorStoreField.provider_annotations` is copied when the field is created and carries mutable provider-specific + configuration; the frozen field protects core schema attributes, not nested provider values +- Key fields can declare `is_auto_generated=True`; connectors decide which generated key types they support +- Search already supports provider-side query vectorization: without a local generator, connectors receive the + original `values` and `vector=None` +- Server-side write vectorization needs no core flag; leave that field out of `generate_vectors` so its source value + reaches a connector that supports it +- Dense vectors support numeric sequences and binary `bytes`; supplied and generated mutable `bytearray` values + normalize to `bytes` +- Float16, float32, float64, and integer element support remains connector-specific through `type_` and + `supported_vector_types` +- Sparse vectors remain provider-native values supplied through model codecs or `search(values=...)`; core does not + define a sparse representation or dense+sparse fusion mode + +#### 4.1.2 — Dense vector dimension checks +- Enforce declared dimensions at the shared write and search boundaries by default. This replaces the earlier + decision to leave dense length enforcement entirely to providers +- Check sequence length only, without copying, converting, or scanning elements solely for validation. Resolve + vector fields and storage names once per write batch, and validate final values after any local generation +- Null vectors remain allowed. Source text and non-sequence provider-native values are not treated as dense + vectors; `bytes`/`bytearray` length is not assumed to equal dimensionality. Connectors validate these representations +- The contract covers materialized non-string, non-binary sequences, not arbitrary provider-specific encodings, + numeric element validity, or revalidation on retrieval. Provider-side vectorization remains unchanged +- Local benchmarking of 1,000-record batches found length checking inexpensive relative to serialization and + in-memory copying. Use a straightforward pass without an opt-out flag or a more complex serialization path + +#### 4.2 — Derive search-tool parameters from filters +- A `Param` used as a complete filter value defines its model-visible name, native type, default, and constraints +- The tool factory emits a closed JSON Schema and resolves parameters before search +- An absent optional parameter without a default removes its containing filter; fixed filters remain unchanged +- `omit_if_none=True` also removes the leaf when its resolved argument is `None` (JSON `null`). It requires a + nullable type and an explicit `default=None`, for example + `Param("text", str | None, default=None, omit_if_none=True)`. Absent/null arguments omit the leaf; non-null + arguments retain normal type, constraint, and operator validation. This policy is not supported for paging. +- AND/OR groups evaluate their remaining children, not a `True` replacement for an omitted leaf. Groups left + empty, including NOT groups whose child is removed, are removed recursively. If the whole tree is removed, + search receives no filter; other search options still apply. +- Without the opt-in, explicit null values retain normal validation and provider semantics. Strings such as + `"*"` are literal filter values, not omission markers. +- String operators reject non-string operands centrally, after substitution for parameterized leaves +- Defaults and supplied mutable parameter values are copied per invocation, including nested containers +- Bound structural inspection before copying: filter depth/node limits apply to the tree and collection members, + including non-sequence collections such as sets; mapping keys cannot hide a `Param`. Limits also apply to + search tools whose search implementation has no collection definition. Unknown field names remain + connector-owned in that case +- Structural budgets do not sandbox arbitrary provider-native objects or trusted Python hooks + +#### 4.3 — Add `InMemoryCollection` and `InMemoryStore` +- Dedicated `_in_memory.py` module +- Shared process-local collection state, full CRUD/listing/order behavior, and flat vector search +- Pure-Python distance functions with no NumPy or SciPy dependency +- Scoring, filters, and thresholds execute locally before paging. `DEFAULT` resolves to cosine distance and + therefore accepts scores at or below the threshold, including zero for identical vectors +- Cosine calculations scale each vector independently to avoid overflow/underflow from finite magnitudes. + Non-finite scores are rejected for every metric, and unsupported distance functions fail before scanning records +- Hamming distance is the proportion of unequal dimensions, not a mismatch count; scores and thresholds use + the range zero to one, consistent with `scipy.spatial.distance.hamming` +- Strict filter evaluator over serialized mappings with the shared conservative resource limits +- Dictionary inputs and custom encoder outputs both pass through `msgspec.to_builtins` before storage, so + ordinary filtering operates on normalized data rather than original object comparison methods. Custom codecs + and connector overrides are trusted Python code, not a sandbox + +#### 4.4 — Tests and samples +- Direct filter composition and model-set search-tool filter parameters +- Security regressions for fail-closed behavior, scope preservation, and the SK exploit class +- FAISS remains deferred to its own optional connector phase --- @@ -390,8 +494,8 @@ Each connector follows the AF package structure: 8. **`create_vector_search_tool`**: The AF-native equivalent of SK's `create_search_function`. Instead of creating a `KernelFunction`, this creates an AF `FunctionTool` from any `SupportsVectorSearch` implementation. This allows agents to use vector search as a tool during conversations. Design: - `create_vector_search_tool(search, name, description, search_type, ...)` returns a `FunctionTool` - - The tool accepts declared parameters, performs embedding + vector search, and returns text or multimodal content - - Defaults to `query`; custom parameters can expose `top`, `skip`, and additional fields for the filter mapper + - The tool accepts `query` plus `Param` values discovered in filters or paging options + - It generates a closed native JSON Schema, performs embedding + vector search, and returns text or multimodal content - Lives in `_vectors.py` without expanding the structural search protocol 9. **CRUD tools**: A full set of create/read/update/delete tools for vector store collections, allowing agents to manage data in vector stores. Design: @@ -400,4 +504,11 @@ Each connector follows the AF package structure: - `create_delete_tool(...)` → tool for deleting records - These are separate from search and are placed in a later phase -10. **Score threshold filtering**: `search(score_threshold=...)` filters results by relevance score (ref: [SK .NET PR #13501](https://github.com/microsoft/semantic-kernel/pull/13501)). The semantics depend on the distance function: for similarity functions (cosine similarity, dot product), results *below* the threshold are filtered out; for distance functions (cosine distance, euclidean), results *above* the threshold are filtered out. Use `DISTANCE_FUNCTION_DIRECTION_HELPER` to determine direction. Connectors should implement this natively where the database supports it, falling back to client-side post-filtering otherwise. +10. **Score threshold filtering**: Scoring, filter execution, score thresholds, and paging belong to the connector + and backing store (ref: [SK .NET PR #13501](https://github.com/microsoft/semantic-kernel/pull/13501)). Core passes + `score_threshold` through without requiring a known distance function or an explicit metric and does not + post-filter returned results, including results without scores. Each connector defines its score units, + threshold direction, and default metric. Execute filtering and thresholding natively where supported; + otherwise implement an explicit connector-local fallback coordinated with paging, or reject the unsupported + option rather than silently ignoring it. `DISTANCE_FUNCTION_DIRECTION_HELPER` remains available for + connectors implementing comparisons for common metrics locally; it is not a core capability gate. diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index 7339fcd9b74..447f3bbafc4 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -73,17 +73,50 @@ agent_framework/ - **`@tool`** decorator - Converts functions to tools - **`use_function_invocation()`** - Decorator to add automatic function calling to chat clients -### Vector stores (`_vectors.py`) +### Vector stores The vector store API is experimental under the shared `VECTOR_STORES` feature ID. - **`@vectorstoremodel`** - Declares key, data, and vector fields on dataclasses, Pydantic models, and plain classes - **`register_vectorstoremodel`** - Registers one definition and msgspec-backed codec pair per model type +- **`VectorStoreField`** - Frozen core key/data/vector metadata; common index and distance values remain open to + provider-defined strings, key fields can be store-generated, and copied `provider_annotations` remains mutable for + connector-specific configuration +- **`Filter` / `FilterGroup`** - Mutable data-only filter inputs shared by local and remote vector stores; collection + operations bound structural traversal before copying and pass an independent snapshot to connectors. Parameter + detection includes collection members and mapping keys; string operators require string operands after resolution +- **`Param`** - Native typed search-tool parameter reference embedded in filter values or paging options + - Defaults and supplied mutable values are copied per filter invocation, including for definition-less search tools + - Filter parameters may opt into null omission with a nullable type and explicit `default=None`, such as + `Param("text", str | None, default=None, omit_if_none=True)`; absent/null arguments remove that leaf, while + remaining AND/OR children still apply. Empty groups (including NOT with an omitted child) are removed + recursively; removing the whole tree means no filter. Paging parameters do not support null omission. - **`BaseVectorCollection`** - Base class for collection lifecycle and msgspec-backed record CRUD operations; - upserts generate embeddings by default and retrieval excludes vectors by default + upserts generate embeddings by default, retrieval excludes vectors by default, and filtered retrieval is an + alternate mode to key lookup +- **Embedding generation selection** - `generate_vectors=True` regenerates every vector field, `False` preserves all + values, and a list or tuple of logical vector field names generates only those fields so connectors can combine + local, precomputed, and provider-side vectorization +- **Vector payloads** - Shared dense query/generated vectors accept numeric sequences and binary bytes; connectors + declare supported element/representation types. Sparse vectors remain provider-native through codecs or + `search(values=...)`, not a core sparse type. +- **Vector dimensions** - Final dense sequence lengths are checked after optional generation for the whole write + batch before connector conversion or writes, and for the selected query field before search dispatch. In-memory + queries also check their normalized numeric sequence, including array-like inputs and empty collections. These + are length checks, not element validation; null vectors, source text, binary payloads, and non-sequence + provider-native representations remain connector-owned. - **`BaseVectorStore`** - Base class for stores that create collection clients -- **`BaseVectorSearch`** - Base class for vector and keyword-hybrid search +- **`BaseVectorSearch`** - Base class for vector and keyword-hybrid search; core validates portable requests and + deserializes results without interpreting thresholds or re-filtering returned scores. Connectors own scoring, + filter execution, score thresholds (including provider-defined/default metrics), and paging. Use native backend + execution where available, otherwise an explicit connector-local fallback or reject unsupported options - **`create_vector_search_tool`** - Creates an agent tool from any `SupportsVectorSearch` implementation +- **`InMemoryCollection` / `InMemoryStore`** - Dependency-free, process-local development and test implementation; + cosine scoring scales finite inputs, all metrics reject non-finite scores, and unsupported distance functions + fail before record scanning. Hamming scores/thresholds use the fraction of unequal dimensions, not a count. + Scoring, filtering, and thresholds run locally before paging; `DEFAULT` means cosine distance and uses a maximum + distance threshold. + Shared serialization normalizes stored data; codecs and connector overrides remain trusted Python code - **`SupportsVectorUpsert`** / **`SupportsVectorSearch`** - Structural protocols for vector store capabilities ### Middleware (`_middleware.py`) diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index 241bcda3aae..c80b5025eb0 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -284,6 +284,17 @@ "validate_tool_mode", "validate_tools", ), + "._in_memory": ( + "InMemoryCollection", + "InMemoryStore", + ), + "._vector_filters": ( + "Filter", + "FilterGroup", + "FilterGroupOperator", + "FilterOperator", + "Param", + ), "._vectors": ( "DISTANCE_FUNCTION_DIRECTION_HELPER", "BaseVectorCollection", @@ -291,6 +302,7 @@ "BaseVectorStore", "DistanceFunction", "FieldTypes", + "GenerateVectors", "IndexKind", "SearchResponse", "SearchResults", @@ -493,6 +505,10 @@ "FileSkillsSource", "FileStoreEntry", "FileSystemAgentFileStore", + "Filter", + "FilterGroup", + "FilterGroupOperator", + "FilterOperator", "FilteringSkillsSource", "FinalT", "FinishReason", @@ -507,13 +523,16 @@ "FunctionalWorkflow", "FunctionalWorkflowAgent", "FunctionalWorkflowDefinition", + "GenerateVectors", "GeneratedEmbeddings", "GraphConnectivityError", "HistoryProvider", "InMemoryAgentFileStore", "InMemoryCheckpointStorage", + "InMemoryCollection", "InMemoryHistoryProvider", "InMemorySkillsSource", + "InMemoryStore", "InProcRunnerContext", "IndexKind", "InlineSkill", @@ -543,6 +562,7 @@ "MiddlewareTypes", "OuterFinalT", "OuterUpdateT", + "Param", "RawAgent", "ReleaseCandidateFeature", "ResponseStream", diff --git a/python/packages/core/agent_framework/__init__.pyi b/python/packages/core/agent_framework/__init__.pyi index 5816c90600f..a18848f56f3 100644 --- a/python/packages/core/agent_framework/__init__.pyi +++ b/python/packages/core/agent_framework/__init__.pyi @@ -123,6 +123,7 @@ from ._harness._tool_approval import ( create_always_approve_tool_response, create_always_approve_tool_with_arguments_response, ) +from ._in_memory import InMemoryCollection, InMemoryStore from ._mcp import ( MCPStdioTool, MCPStreamableHTTPTool, @@ -250,6 +251,7 @@ from ._types import ( validate_tool_mode, validate_tools, ) +from ._vector_filters import Filter, FilterGroup, FilterGroupOperator, FilterOperator, Param from ._vectors import ( DISTANCE_FUNCTION_DIRECTION_HELPER, BaseVectorCollection, @@ -257,6 +259,7 @@ from ._vectors import ( BaseVectorStore, DistanceFunction, FieldTypes, + GenerateVectors, IndexKind, SearchResponse, SearchResults, @@ -457,6 +460,10 @@ __all__ = [ "FileSkillsSource", "FileStoreEntry", "FileSystemAgentFileStore", + "Filter", + "FilterGroup", + "FilterGroupOperator", + "FilterOperator", "FilteringSkillsSource", "FinalT", "FinishReason", @@ -471,13 +478,16 @@ __all__ = [ "FunctionalWorkflow", "FunctionalWorkflowAgent", "FunctionalWorkflowDefinition", + "GenerateVectors", "GeneratedEmbeddings", "GraphConnectivityError", "HistoryProvider", "InMemoryAgentFileStore", "InMemoryCheckpointStorage", + "InMemoryCollection", "InMemoryHistoryProvider", "InMemorySkillsSource", + "InMemoryStore", "InProcRunnerContext", "IndexKind", "InlineSkill", @@ -507,6 +517,7 @@ __all__ = [ "MiddlewareTypes", "OuterFinalT", "OuterUpdateT", + "Param", "RawAgent", "ReleaseCandidateFeature", "ResponseStream", diff --git a/python/packages/core/agent_framework/_in_memory.py b/python/packages/core/agent_framework/_in_memory.py new file mode 100644 index 00000000000..b816aeabccf --- /dev/null +++ b/python/packages/core/agent_framework/_in_memory.py @@ -0,0 +1,604 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Dependency-free in-memory vector store.""" + +from __future__ import annotations + +import math +from collections.abc import Collection, Mapping, Sequence +from copy import deepcopy +from dataclasses import dataclass +from datetime import date, datetime, time, timedelta +from decimal import Decimal +from typing import Any, ClassVar, Generic, cast +from uuid import UUID, uuid4 + +from typing_extensions import TypeVar + +from ._feature_stage import ExperimentalFeature, experimental +from ._telemetry import FeatureIndex, mark_feature_used +from ._vector_filters import ( + Filter, + FilterExpression, + FilterGroup, + filter_values_equal, + require_filter_collection, + require_filter_string, + validate_filter, +) +from ._vectors import ( + DISTANCE_FUNCTION_DIRECTION_HELPER, + BaseVectorCollection, + BaseVectorSearch, + BaseVectorStore, + DistanceFunction, + EmbeddingClient, + SearchResults, + SearchType, + Vector, + VectorStoreCollectionDefinition, + _validate_vector_dimensions, # pyright: ignore[reportPrivateUsage] +) +from .exceptions import IntegrationException + +KeyT = TypeVar("KeyT", default=Any) +ModelT = TypeVar("ModelT", default=Any) + +_IN_MEMORY_FILTER_OPERATORS = frozenset({ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "between", + "in", + "not_in", + "is_null", + "is_not_null", + "exists", + "contains", + "contains_any", + "contains_all", + "starts_with", + "ends_with", + "contains_text", +}) +_SCALAR_FILTER_TYPES = (str, int, float, bool, bytes, date, datetime, time, timedelta, Decimal, UUID) +_DESCENDING_DISTANCE_FUNCTIONS = frozenset({"cosine_similarity", "dot_prod"}) +_IN_MEMORY_DISTANCE_FUNCTIONS = frozenset({ + "cosine_similarity", + "cosine_distance", + "dot_prod", + "negative_dot_prod", + "euclidean_distance", + "euclidean_squared_distance", + "manhattan", + "hamming", + "DEFAULT", +}) + + +@dataclass(slots=True) +class _InMemoryCollectionState: + definition: VectorStoreCollectionDefinition + records: dict[Any, dict[str, Any]] + exists: bool = False + + +def _numeric_vector(value: Any, *, field_name: str) -> tuple[float, ...]: + to_list = getattr(value, "tolist", None) + if callable(to_list): + value = to_list() + if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)): + raise TypeError(f"Vector field '{field_name}' must contain a numeric sequence.") + vector: list[float] = [] + for item in cast(Sequence[Any], value): + if not isinstance(item, int | float) or isinstance(item, bool): + raise TypeError(f"Vector field '{field_name}' must contain only numbers.") + number = float(item) + if not math.isfinite(number): + raise ValueError(f"Vector field '{field_name}' must contain only finite numbers.") + vector.append(number) + if not vector: + raise ValueError(f"Vector field '{field_name}' cannot be empty.") + return tuple(vector) + + +def _paired_vectors(left: Vector, right: Vector) -> tuple[tuple[float, ...], tuple[float, ...]]: + normalized_left = _numeric_vector(left, field_name="query") + normalized_right = _numeric_vector(right, field_name="stored") + if len(normalized_left) != len(normalized_right): + raise ValueError( + f"Query and stored vectors must have the same length; " + f"got {len(normalized_left)} and {len(normalized_right)}." + ) + return normalized_left, normalized_right + + +def _calculate_score(left: Vector, right: Vector, distance_function: DistanceFunction) -> float: + left_values, right_values = _paired_vectors(left, right) + if distance_function in ("cosine_similarity", "cosine_distance", "DEFAULT"): + left_scale = max(abs(value) for value in left_values) + right_scale = max(abs(value) for value in right_values) + if left_scale == 0 or right_scale == 0: + raise ValueError("Cosine distance is undefined for zero-magnitude vectors.") + left_scaled = tuple(value / left_scale for value in left_values) + right_scaled = tuple(value / right_scale for value in right_values) + dot_product = math.fsum(a * b for a, b in zip(left_scaled, right_scaled, strict=True)) + left_norm = math.sqrt(math.fsum(value * value for value in left_scaled)) + right_norm = math.sqrt(math.fsum(value * value for value in right_scaled)) + similarity = dot_product / (left_norm * right_norm) + if not math.isfinite(similarity): + raise ValueError("Cosine similarity must be finite.") + similarity = max(-1.0, min(1.0, similarity)) + score = similarity if distance_function == "cosine_similarity" else 1 - similarity + elif distance_function == "dot_prod": + score = sum(a * b for a, b in zip(left_values, right_values, strict=True)) + elif distance_function == "negative_dot_prod": + score = -sum(a * b for a, b in zip(left_values, right_values, strict=True)) + elif distance_function == "euclidean_distance": + score = math.dist(left_values, right_values) + elif distance_function == "euclidean_squared_distance": + score = sum((a - b) * (a - b) for a, b in zip(left_values, right_values, strict=True)) + elif distance_function == "manhattan": + score = sum(abs(a - b) for a, b in zip(left_values, right_values, strict=True)) + elif distance_function == "hamming": + score = sum(a != b for a, b in zip(left_values, right_values, strict=True)) / len(left_values) + else: + raise NotImplementedError(f"Distance function '{distance_function}' is not supported by InMemoryCollection.") + if not math.isfinite(score): + raise ValueError(f"Distance function '{distance_function}' produced a non-finite score.") + return score + + +def _validate_in_memory_filter_value(value: Any) -> None: + if value is None or isinstance(value, _SCALAR_FILTER_TYPES): + if isinstance(value, float) and not math.isfinite(value): + raise ValueError("In-memory filter values must be finite.") + if isinstance(value, Decimal) and not value.is_finite(): + raise ValueError("In-memory filter values must be finite.") + return + if isinstance(value, Mapping): + raise TypeError("InMemoryCollection does not support mapping filter values.") + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + for item in cast(Sequence[Any], value): + _validate_in_memory_filter_value(item) + return + raise TypeError(f"InMemoryCollection does not support filter values of type '{type(value).__name__}'.") + + +def _resolve_record_value( + record: Mapping[str, Any], + filter_: Filter, + definition: VectorStoreCollectionDefinition, +) -> tuple[bool, Any]: + if "." in filter_.field_name: + raise NotImplementedError("InMemoryCollection does not support nested filter field paths.") + field = definition.try_get_field(filter_.field_name) + if field is None: + raise ValueError(f"Filter field '{filter_.field_name}' is not part of the vector store definition.") + storage_name = field.storage_name or field.name + return storage_name in record, record.get(storage_name) + + +def _evaluate_filter( + expression: FilterExpression, + record: Mapping[str, Any], + definition: VectorStoreCollectionDefinition, +) -> bool: + if isinstance(expression, FilterGroup): + values = (_evaluate_filter(item, record, definition) for item in expression.filters) + match expression.operator: + case "and": + return all(values) + case "or": + return any(values) + case "not": + return not next(values) + case _: + raise ValueError(f"Unknown filter group operator '{expression.operator}'.") + + exists, actual = _resolve_record_value(record, expression, definition) + operator = expression.operator + expected = expression.value + if operator == "exists": + return exists + if operator == "is_null": + return exists and actual is None + if operator == "is_not_null": + return exists and actual is not None + if not exists: + return False + if actual is None and operator not in ("eq", "ne"): + return False + + try: + match operator: + case "eq": + return filter_values_equal(actual, expected) + case "ne": + return not filter_values_equal(actual, expected) + case "gt": + return actual > expected + case "gte": + return actual >= expected + case "lt": + return actual < expected + case "lte": + return actual <= expected + case "between": + lower, upper = cast(Sequence[Any], expected) + return lower <= actual <= upper + case "in": + return any(filter_values_equal(actual, item) for item in cast(Collection[Any], expected)) + case "not_in": + return all(not filter_values_equal(actual, item) for item in cast(Collection[Any], expected)) + case "contains": + return any(filter_values_equal(item, expected) for item in require_filter_collection(actual)) + case "contains_any": + collection = require_filter_collection(actual) + return any( + filter_values_equal(item, value) for value in cast(Sequence[Any], expected) for item in collection + ) + case "contains_all": + collection = require_filter_collection(actual) + return all( + any(filter_values_equal(item, value) for item in collection) + for value in cast(Sequence[Any], expected) + ) + case "starts_with": + return require_filter_string(actual).startswith(require_filter_string(expected)) + case "ends_with": + return require_filter_string(actual).endswith(require_filter_string(expected)) + case "contains_text": + return require_filter_string(expected) in require_filter_string(actual) + case _: + raise NotImplementedError(f"Filter operator '{operator}' is not supported by InMemoryCollection.") + except TypeError as exc: + raise ValueError( + f"Filter operator '{operator}' cannot compare field '{expression.field_name}' with value {expected!r}." + ) from exc + + +@experimental(feature_id=ExperimentalFeature.VECTOR_STORES) +class InMemoryCollection( + BaseVectorCollection[KeyT, ModelT], + BaseVectorSearch[KeyT, ModelT], + Generic[KeyT, ModelT], +): + """Store and search vector records in process memory. + + This implementation is intended for tests and development. It is + nonpersistent, uses linear scans, and is not thread-safe. + + Scoring, filtering, and score thresholds are applied locally before paging. + The default metric is cosine distance, so its threshold is a maximum distance. + Hamming scores are the proportion of unequal dimensions, between zero and + one, not a mismatch count. Non-finite scores are rejected. Records are + normalized by the shared serializer before storage; custom codecs and + connector overrides remain trusted Python code, not sandboxed execution. + """ + + supported_search_types: ClassVar[set[SearchType]] = {"vector"} + supported_vector_types: ClassVar[set[str] | None] = { + "float", + "float16", + "float32", + "float64", + "int", + "int8", + "int16", + "int32", + "int64", + "uint8", + "uint16", + "uint32", + "uint64", + } + + def __init__( + self, + record_type: type[ModelT], + *, + definition: VectorStoreCollectionDefinition | None = None, + collection_name: str | None = None, + embedding_generator: EmbeddingClient | None = None, + ) -> None: + """Initialize an in-memory collection. + + Args: + record_type: The application record type. + definition: An explicit definition for dictionary or externally owned records. + collection_name: The collection name, overriding the model definition. + embedding_generator: The default client used to generate vectors. + """ + super().__init__( + record_type, + definition=definition, + collection_name=collection_name, + embedding_generator=embedding_generator, + ) + self._state = _InMemoryCollectionState(self.definition, {}) + + def _require_collection(self) -> None: + if not self._state.exists: + raise IntegrationException(f"Collection '{self.collection_name}' does not exist.") + + async def ensure_collection_exists( + self, + *, + operation_options: Mapping[str, Any] | None = None, + ) -> None: + """Create the collection when it does not exist.""" + mark_feature_used(FeatureIndex.CORE_VECTOR_STORES) + self._state.exists = True + + async def collection_exists( + self, + *, + operation_options: Mapping[str, Any] | None = None, + ) -> bool: + """Return whether the collection exists.""" + mark_feature_used(FeatureIndex.CORE_VECTOR_STORES) + return self._state.exists + + async def ensure_collection_deleted( + self, + *, + operation_options: Mapping[str, Any] | None = None, + ) -> None: + """Delete all records and mark the collection as absent.""" + mark_feature_used(FeatureIndex.CORE_VECTOR_STORES) + self._state.records.clear() + self._state.exists = False + + async def _inner_upsert( + self, + records: Sequence[Any], + *, + operation_options: Mapping[str, Any] | None = None, + ) -> Sequence[KeyT]: + self._require_collection() + keys: list[KeyT] = [] + for record in records: + if not isinstance(record, dict): + raise TypeError("In-memory records must serialize to dictionaries.") + stored_record = deepcopy(cast(dict[str, Any], record)) + key_field = self.definition.key_field + key_storage_name = self.definition.key_field_storage_name + if key_storage_name not in stored_record: + if not key_field.is_auto_generated: + raise ValueError(f"Record is missing key field '{key_field.name}'.") + if key_field.type_ == "str": + stored_record[key_storage_name] = str(uuid4()) + else: + raise NotImplementedError("InMemoryCollection can auto-generate only string keys.") + key = stored_record[key_storage_name] + try: + hash(key) + except TypeError as exc: + raise TypeError("In-memory record keys must be hashable.") from exc + self._state.records[key] = stored_record + keys.append(cast(KeyT, key)) + return keys + + async def _inner_get( + self, + *, + keys: Sequence[KeyT] | None = None, + filter: FilterExpression | None = None, + top: int = 10, + skip: int = 0, + order_by: Mapping[str, bool] | None = None, + include_vectors: bool = False, + operation_options: Mapping[str, Any] | None = None, + ) -> Sequence[Any] | None: + self._require_collection() + if keys is not None: + return [deepcopy(self._state.records[key]) for key in keys if key in self._state.records] + records = list(self._state.records.values()) + if filter is not None: + _validate_in_memory_filter(filter, self.definition) + records = [record for record in records if _evaluate_filter(filter, record, self.definition)] + if order_by: + for field_name, ascending in reversed(tuple(order_by.items())): + if not isinstance(ascending, bool): + raise TypeError(f"Order direction for field '{field_name}' must be a boolean.") + field = self.definition.try_get_field(field_name) + if field is None: + raise ValueError(f"Order field '{field_name}' is not part of the vector store definition.") + storage_name = field.storage_name or field.name + try: + records_with_values = [record for record in records if record[storage_name] is not None] + records_without_values = [record for record in records if record[storage_name] is None] + records_with_values.sort(key=lambda record: record[storage_name], reverse=not ascending) + records[:] = [*records_with_values, *records_without_values] + except (KeyError, TypeError) as exc: + raise ValueError(f"Records cannot be ordered by field '{field_name}'.") from exc + return deepcopy(records[skip : skip + top]) + + async def _inner_delete( + self, + keys: Sequence[KeyT], + *, + operation_options: Mapping[str, Any] | None = None, + ) -> None: + self._require_collection() + for key in keys: + self._state.records.pop(key, None) + + async def _inner_search( + self, + *, + search_type: SearchType, + filter: FilterExpression | None = None, + values: Any | None = None, + vector: Vector | None = None, + top: int = 3, + skip: int = 0, + include_vectors: bool = False, + vector_property_name: str | None = None, + additional_property_name: str | None = None, + score_threshold: float | None = None, + operation_options: Mapping[str, Any] | None = None, + ) -> SearchResults[Any]: + self._require_collection() + if search_type != "vector": + raise NotImplementedError("InMemoryCollection supports only vector search.") + if vector is None: + raise ValueError("InMemoryCollection vector search requires a query vector or embedding generator.") + query_vector = _numeric_vector(vector, field_name="query") + vector_field = self.definition.try_get_vector_field(vector_property_name) + if vector_field is None: + raise ValueError("InMemoryCollection vector search requires a vector field.") + _validate_vector_dimensions(query_vector, vector_field) + if filter is not None: + _validate_in_memory_filter(filter, self.definition) + + distance_function = vector_field.distance_function or "DEFAULT" + if distance_function == "DEFAULT": + distance_function = "cosine_distance" + if distance_function not in _IN_MEMORY_DISTANCE_FUNCTIONS: + raise NotImplementedError( + f"Distance function '{distance_function}' is not supported by InMemoryCollection." + ) + comparison = DISTANCE_FUNCTION_DIRECTION_HELPER[distance_function] + storage_name = vector_field.storage_name or vector_field.name + results: list[dict[str, Any]] = [] + key_storage_name = self.definition.key_field_storage_name + for record in self._state.records.values(): + if filter is not None and not _evaluate_filter(filter, record, self.definition): + continue + stored_vector = record.get(storage_name) + if stored_vector is None: + continue + try: + score = _calculate_score(query_vector, stored_vector, distance_function) + except TypeError as exc: + raise TypeError( + f"Record {record.get(key_storage_name)!r} has an invalid vector in field '{storage_name}': {exc}" + ) from exc + except ValueError as exc: + raise ValueError( + f"Record {record.get(key_storage_name)!r} has an invalid vector in field '{storage_name}': {exc}" + ) from exc + if score_threshold is not None and not comparison(score, score_threshold): + continue + results.append({"record": deepcopy(record), "score": score}) + results.sort( + key=lambda result: cast(float, result["score"]), + reverse=distance_function in _DESCENDING_DISTANCE_FUNCTIONS, + ) + total_count = len(results) + return SearchResults( + results[skip : skip + top], + metadata={"in_memory_total_count": total_count}, + ) + + def _get_record_from_result(self, result: Any) -> Any: + if not isinstance(result, Mapping): + raise TypeError("In-memory search results must be mappings.") + return cast(Mapping[str, Any], result)["record"] + + def _get_score_from_result(self, result: Any) -> float | None: + if not isinstance(result, Mapping): + raise TypeError("In-memory search results must be mappings.") + score = cast(Mapping[str, Any], result).get("score") + return cast(float | None, score) + + +def _walk_filters(expression: FilterExpression) -> tuple[Filter, ...]: + if isinstance(expression, Filter): + return (expression,) + return tuple(item for child in expression.filters for item in _walk_filters(child)) + + +def _validate_in_memory_filter( + expression: FilterExpression, + definition: VectorStoreCollectionDefinition, +) -> None: + validate_filter(expression, field_names=definition.names) + for item in _walk_filters(expression): + if item.operator not in _IN_MEMORY_FILTER_OPERATORS: + raise NotImplementedError(f"Filter operator '{item.operator}' is not supported by InMemoryCollection.") + if "." in item.field_name: + raise NotImplementedError("InMemoryCollection does not support nested filter field paths.") + _validate_in_memory_filter_value(item.value) + + +@experimental(feature_id=ExperimentalFeature.VECTOR_STORES) +class InMemoryStore(BaseVectorStore): + """Create in-memory collection clients that share process-local state.""" + + def __init__( + self, + *, + embedding_generator: EmbeddingClient | None = None, + ) -> None: + """Initialize an in-memory vector store. + + Args: + embedding_generator: The default client used by collection search and upsert operations. + """ + super().__init__(embedding_generator=embedding_generator) + self._collections: dict[str, _InMemoryCollectionState] = {} + + def get_collection( + self, + record_type: type[ModelT], + *, + definition: VectorStoreCollectionDefinition | None = None, + collection_name: str | None = None, + embedding_generator: EmbeddingClient | None = None, + ) -> InMemoryCollection[Any, ModelT]: + """Create a collection client tied to shared in-memory state. + + Args: + record_type: The application record type. + definition: An explicit definition for dictionary or externally owned records. + collection_name: The collection name, overriding the model definition. + embedding_generator: A collection-specific embedding client. + + Returns: + A collection client sharing state with other clients for the same name. + + Raises: + ValueError: If the collection name is already associated with another definition. + """ + mark_feature_used(FeatureIndex.CORE_VECTOR_STORES) + collection = InMemoryCollection( + record_type, + definition=definition, + collection_name=collection_name, + embedding_generator=embedding_generator if embedding_generator is not None else self.embedding_generator, + ) + state = self._collections.get(collection.collection_name) + if state is None: + self._collections[collection.collection_name] = collection._state # pyright: ignore[reportPrivateUsage] + return collection + if state.definition != collection.definition: + raise ValueError( + f"Collection '{collection.collection_name}' is already registered with another definition." + ) + collection._state = state # pyright: ignore[reportPrivateUsage] + return collection + + async def list_collection_names( + self, + *, + operation_options: Mapping[str, Any] | None = None, + ) -> Sequence[str]: + """List existing in-memory collection names.""" + mark_feature_used(FeatureIndex.CORE_VECTOR_STORES) + return sorted(name for name, state in self._collections.items() if state.exists) + + async def _inner_ensure_collection_deleted( + self, + collection_name: str, + *, + operation_options: Mapping[str, Any] | None = None, + ) -> None: + state = self._collections[collection_name] + state.records.clear() + state.exists = False diff --git a/python/packages/core/agent_framework/_vector_filters.py b/python/packages/core/agent_framework/_vector_filters.py new file mode 100644 index 00000000000..c4488c715c1 --- /dev/null +++ b/python/packages/core/agent_framework/_vector_filters.py @@ -0,0 +1,874 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Portable vector store filter expressions. + +Common operator semantics: + +- ``eq`` / ``ne`` compare one field value and treat booleans as distinct from numbers. +- ``gt`` / ``gte`` / ``lt`` / ``lte`` perform ordered scalar comparisons. +- ``between`` is inclusive and accepts exactly ``(lower, upper)``. +- ``in`` / ``not_in`` test whether the field value occurs in the supplied sequence. +- ``contains`` tests whether a non-mapping collection field contains one supplied value. +- ``contains_any`` / ``contains_all`` test collection fields against supplied sequences. +- ``is_null`` / ``is_not_null`` require the field to exist; use ``exists`` to test presence alone. +- ``starts_with`` / ``ends_with`` / ``contains_text`` require string operands. + +A missing field returns ``False`` for every operator except ``exists``. Use +``FilterGroup`` for explicit AND, OR, and NOT composition. Provider-specific +operators must be namespaced and are interpreted only by their connector. +""" + +from __future__ import annotations + +import keyword +import math +import re +from collections.abc import Collection, Mapping, Sequence +from copy import deepcopy +from dataclasses import dataclass +from decimal import Decimal +from itertools import islice +from types import UnionType +from typing import Any, Final, Literal, TypeAlias, Union, cast, get_args, get_origin + +from typing_extensions import Sentinel + +from ._feature_stage import ExperimentalFeature, experimental + +FilterOperator: TypeAlias = ( + Literal[ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "between", + "in", + "not_in", + "is_null", + "is_not_null", + "exists", + "contains", + "contains_any", + "contains_all", + "starts_with", + "ends_with", + "contains_text", + ] + | str +) +FilterGroupOperator: TypeAlias = Literal["and", "or", "not"] + +_STANDARD_FILTER_OPERATORS: Final[frozenset[str]] = frozenset({ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "between", + "in", + "not_in", + "is_null", + "is_not_null", + "exists", + "contains", + "contains_any", + "contains_all", + "starts_with", + "ends_with", + "contains_text", +}) +_NO_VALUE_OPERATORS: Final[frozenset[str]] = frozenset({"is_null", "is_not_null", "exists"}) +_TEXT_VALUE_OPERATORS: Final[frozenset[str]] = frozenset({"starts_with", "ends_with", "contains_text"}) +_SEQUENCE_VALUE_OPERATORS: Final[frozenset[str]] = frozenset({ + "between", + "in", + "not_in", + "contains_any", + "contains_all", +}) +_GROUP_OPERATORS: Final[frozenset[str]] = frozenset({"and", "or", "not"}) +_PROVIDER_OPERATOR_PATTERN: Final[re.Pattern[str]] = re.compile(r"[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+") +_PARAM_UNSET = Sentinel("_PARAM_UNSET") +_OMIT_FILTER = Sentinel("_OMIT_FILTER") +_MAX_FILTER_DEPTH: Final[int] = 8 +_MAX_FILTER_NODES: Final[int] = 64 + + +def _is_non_string_sequence(value: Any) -> bool: + return isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)) + + +def require_filter_collection(value: Any) -> Collection[Any]: + """Return a non-string, non-mapping collection filter value.""" + if not isinstance(value, Collection) or isinstance(value, (str, bytes, bytearray, Mapping)): + raise TypeError("Filter value must be a non-string, non-mapping collection.") + return cast(Collection[Any], value) + + +def require_filter_string(value: Any) -> str: + """Return a string filter value.""" + if not isinstance(value, str): + raise TypeError("Filter value must be a string.") + return value + + +def filter_values_equal(left: Any, right: Any) -> bool: + """Compare validated scalars, sequences, and mappings without equating booleans to numbers.""" + if isinstance(left, bool) or isinstance(right, bool): + return isinstance(left, bool) and isinstance(right, bool) and left == right + if _is_non_string_sequence(left) and _is_non_string_sequence(right): + # Preserve native container semantics, such as lists not equaling tuples. + return left == right and all( + filter_values_equal(left_item, right_item) + for left_item, right_item in zip(cast(Sequence[Any], left), cast(Sequence[Any], right), strict=True) + ) + if isinstance(left, Mapping) and isinstance(right, Mapping): + left_mapping = cast(Mapping[str, Any], left) + right_mapping = cast(Mapping[str, Any], right) + return left_mapping == right_mapping and all( + filter_values_equal(item, right_mapping[key]) for key, item in left_mapping.items() + ) + return left == right + + +def _validate_name(name: str, *, kind: str, allow_path: bool) -> None: + if not name: + raise ValueError(f"{kind} cannot be empty.") + segments = name.split(".") if allow_path else [name] + for segment in segments: + if ( + not segment.isidentifier() + or keyword.iskeyword(segment) + or (segment.startswith("__") and segment.endswith("__")) + ): + raise ValueError(f"Invalid {kind.lower()} '{name}'.") + + +@experimental(feature_id=ExperimentalFeature.VECTOR_STORES) +@dataclass(frozen=True, slots=True, init=False) +class Param: + """Reference one model-set search-tool parameter. + + An optional parameter without a default removes the containing filter when + omitted. Otherwise, the supplied value or declared default is substituted + before the filter reaches the vector store. A missing required parameter + raises an error. + + Set ``omit_if_none=True`` to remove the containing filter when the resolved + value is ``None`` (JSON ``null``). This requires a nullable ``value_type`` + and an explicit ``default=None``, for example + ``Param("text", str | None, default=None, omit_if_none=True)``. Both an + absent argument and an explicit ``None`` then omit the filter. Non-None + values still undergo the declared type and constraint checks. + + Omission removes the entire leaf, not the field value and not a boolean + ``True`` substitute. AND/OR groups evaluate their remaining children; + empty groups, including NOT groups whose child was removed, are removed + recursively. If the whole tree is removed, search receives no filter. + Fixed filters are retained. See ``FilterGroup`` for composition examples. + + With ``omit_if_none=False`` (the default), ``None`` is an ordinary supplied + value subject to type and operator validation, not an omission request. + Null omission is only supported for filter parameters, not paging options. + + Defaults and supplied mutable values are copied for each filter invocation. + Parameter data is bounded before copying or type validation. + """ + + name: str + value_type: Any + required: bool + _default: Any + omit_if_none: bool + description: str | None + minimum: float | int | None + maximum: float | int | None + min_length: int | None + max_length: int | None + + def __init__( + self, + name: str, + value_type: Any, + *, + required: bool = False, + default: Any = _PARAM_UNSET, + omit_if_none: bool = False, + description: str | None = None, + minimum: float | int | None = None, + maximum: float | int | None = None, + min_length: int | None = None, + max_length: int | None = None, + ) -> None: + """Initialize a parameter reference. + + Args: + name: The tool parameter name exposed to the model. + value_type: The Python type used for schema generation and validation. + Must accept ``None``, such as ``str | None``, when ``omit_if_none=True``. + required: Whether the model must supply the parameter. + default: The value used when an optional parameter is omitted. + Must be explicitly set to ``None`` when ``omit_if_none=True``. + omit_if_none: Whether a resolved ``None`` removes the containing filter. + Requires a nullable type and ``default=None``; not supported for paging. + description: The parameter description shown to the model. + minimum: The inclusive minimum for numeric values. + maximum: The inclusive maximum for numeric values. + min_length: The minimum length for string, array, or object values. + max_length: The maximum length for string, array, or object values. + + Raises: + TypeError: If the annotation is unsupported or ``omit_if_none`` is not a boolean. + ValueError: If the name or constraints are invalid, a required parameter declares a default, + or ``omit_if_none=True`` is used without a nullable type and explicit ``default=None``. + """ + _validate_name(name, kind="Parameter name", allow_path=False) + if name == "query": + raise ValueError("'query' is reserved by vector search tools.") + if required and default is not _PARAM_UNSET: + raise ValueError("A required parameter cannot declare a default.") + if not isinstance(omit_if_none, bool): + raise TypeError("Param omit_if_none must be a boolean.") + if omit_if_none and default is not None: + raise ValueError("Param omit_if_none=True requires an explicit default=None.") + if minimum is not None and (isinstance(minimum, bool) or not math.isfinite(minimum)): + raise ValueError("Param minimum must be a finite number.") + if maximum is not None and (isinstance(maximum, bool) or not math.isfinite(maximum)): + raise ValueError("Param maximum must be a finite number.") + if minimum is not None and maximum is not None and minimum > maximum: + raise ValueError("Param minimum cannot exceed maximum.") + if min_length is not None and ( + not isinstance(min_length, int) or isinstance(min_length, bool) or min_length < 0 + ): + raise ValueError("Param min_length must be a non-negative integer.") + if max_length is not None and ( + not isinstance(max_length, int) or isinstance(max_length, bool) or max_length < 0 + ): + raise ValueError("Param max_length must be a non-negative integer.") + if min_length is not None and max_length is not None and min_length > max_length: + raise ValueError("Param min_length cannot exceed max_length.") + if default is not _PARAM_UNSET: + _validate_param_data(default) + object.__setattr__(self, "name", name) + object.__setattr__(self, "value_type", value_type) + object.__setattr__(self, "required", required) + object.__setattr__(self, "_default", deepcopy(default)) + object.__setattr__(self, "omit_if_none", omit_if_none) + object.__setattr__(self, "description", description) + object.__setattr__(self, "minimum", minimum) + object.__setattr__(self, "maximum", maximum) + object.__setattr__(self, "min_length", min_length) + object.__setattr__(self, "max_length", max_length) + param_schema(self) + + @property + def has_default(self) -> bool: + """Return whether the parameter declares a default value.""" + return self._default is not _PARAM_UNSET + + @property + def default(self) -> Any: + """Return an independent copy of the default value.""" + return deepcopy(self._default) + + def __deepcopy__(self, memo: dict[int, Any]) -> Param: + return self + + +def _json_type(value_type: Any) -> str | None: + if value_type is str: + return "string" + if value_type is int: + return "integer" + if value_type is float: + return "number" + if value_type is bool: + return "boolean" + if value_type is type(None): + return "null" + return None + + +def _schema_for_type(value_type: Any) -> dict[str, Any]: + origin = get_origin(value_type) + if origin is Literal: + values = list(get_args(value_type)) + json_types: list[str] = [] + for value in values: + json_type = _json_type(type(value)) + if json_type is None: + raise TypeError("Param Literal values must be JSON-compatible scalar values.") + if isinstance(value, float) and not math.isfinite(value): + raise ValueError("Param Literal numbers must be finite.") + if json_type not in json_types: + json_types.append(json_type) + schema: dict[str, Any] = {"enum": values} + if len(json_types) == 1: + schema["type"] = json_types[0] + else: + schema["anyOf"] = [{"type": json_type} for json_type in json_types] + return schema + if origin in (Union, UnionType): + return {"anyOf": [_schema_for_type(item) for item in get_args(value_type)]} + if origin is tuple: + args = get_args(value_type) + if len(args) != 2 or args[1] is not Ellipsis: + raise TypeError("Param tuple types must use the homogeneous tuple[T, ...] form.") + item_type = args[0] + return {"type": "array", "items": _schema_for_type(item_type)} + if origin in (list, Sequence): + args = get_args(value_type) + item_type = args[0] if args else Any + return {"type": "array", "items": _schema_for_type(item_type)} + if origin in (dict, Mapping): + args = get_args(value_type) + key_annotation, value_annotation = args if len(args) == 2 else (Any, Any) + if key_annotation is not str: + raise TypeError("Param mapping keys must use the str type.") + return {"type": "object", "additionalProperties": _schema_for_type(value_annotation)} + if value_type is Any: + raise TypeError("Param requires an explicit JSON-compatible value type.") + schema_type = _json_type(value_type) + if schema_type is not None: + return {"type": schema_type} + raise TypeError(f"Param type '{value_type}' cannot be represented as JSON Schema.") + + +def param_schema(param: Param) -> dict[str, Any]: + """Build a JSON Schema property for a parameter without Pydantic.""" + schema = _schema_for_type(param.value_type) + schema_types = _schema_types(schema) + if param.omit_if_none and "null" not in schema_types: + raise ValueError("Param omit_if_none=True requires a value type that accepts None.") + non_null_schema_types = schema_types - {"null"} + if (param.minimum is not None or param.maximum is not None) and ( + not non_null_schema_types or not non_null_schema_types <= {"integer", "number"} + ): + raise ValueError("Param numeric constraints require numeric value types.") + if param.description is not None: + schema["description"] = param.description + if param.has_default: + schema["default"] = param.default + if param.minimum is not None: + schema["minimum"] = param.minimum + if param.maximum is not None: + schema["maximum"] = param.maximum + if (param.min_length is not None or param.max_length is not None) and len(non_null_schema_types) != 1: + raise ValueError("Param length constraints require one string, array, or object type.") + schema_type = next(iter(non_null_schema_types), None) + if schema_type not in (None, "string", "array", "object") and ( + param.min_length is not None or param.max_length is not None + ): + raise ValueError("Param length constraints require a string, array, or object type.") + if param.min_length is not None: + length_key = ( + "minLength" if schema_type == "string" else "minProperties" if schema_type == "object" else "minItems" + ) + _set_schema_constraint(schema, schema_type, length_key, param.min_length) + if param.max_length is not None: + length_key = ( + "maxLength" if schema_type == "string" else "maxProperties" if schema_type == "object" else "maxItems" + ) + _set_schema_constraint(schema, schema_type, length_key, param.max_length) + return schema + + +def _schema_types(schema: Mapping[str, Any]) -> set[str]: + schema_type = schema.get("type") + if isinstance(schema_type, str): + return {schema_type} + variants = schema.get("anyOf") + if not isinstance(variants, Sequence): + return set() + return { + item_type + for variant in cast(Sequence[Any], variants) + if isinstance(variant, Mapping) + for item_type in _schema_types(cast(Mapping[str, Any], variant)) + } + + +def _set_schema_constraint( + schema: dict[str, Any], + schema_type: str | None, + key: str, + value: Any, +) -> None: + if schema.get("type") == schema_type: + schema[key] = value + return + for variant in cast(Sequence[Any], schema.get("anyOf", ())): + if isinstance(variant, dict): + typed_variant = cast(dict[str, Any], variant) + if typed_variant.get("type") == schema_type: + typed_variant[key] = value + + +def _matches_param_type(value: Any, value_type: Any) -> bool: + origin = get_origin(value_type) + if origin is Literal: + return any(filter_values_equal(value, item) for item in get_args(value_type)) + if origin in (Union, UnionType): + return any(_matches_param_type(value, item) for item in get_args(value_type)) + if origin is tuple: + if not _is_non_string_sequence(value): + return False + args = get_args(value_type) + if len(args) != 2 or args[1] is not Ellipsis: + return False + return all(_matches_param_type(item, args[0]) for item in cast(Sequence[Any], value)) + if origin in (list, Sequence): + if not _is_non_string_sequence(value): + return False + args = get_args(value_type) + item_type = args[0] if args else Any + return all(_matches_param_type(item, item_type) for item in cast(Sequence[Any], value)) + if origin in (dict, Mapping): + if not isinstance(value, Mapping): + return False + args = get_args(value_type) + key_type, item_type = args if len(args) == 2 else (Any, Any) + mapping = cast(Mapping[Any, Any], value) + return all( + _matches_param_type(key, key_type) and _matches_param_type(item, item_type) for key, item in mapping.items() + ) + if value_type is Any: + return True + if value_type is int: + return isinstance(value, int) and not isinstance(value, bool) + if value_type is float: + return isinstance(value, int | float) and not isinstance(value, bool) + return isinstance(value, value_type) + + +@dataclass(slots=True) +class _ParamDataValidator: + max_depth: int = 16 + max_nodes: int = 256 + node_count: int = 0 + + def validate(self, value: Any, *, seen: set[int] | None = None, depth: int = 0) -> None: + self.node_count += 1 + if self.node_count > self.max_nodes: + raise ValueError(f"Search parameter values cannot contain more than {self.max_nodes} nodes.") + if depth > self.max_depth: + raise ValueError(f"Search parameter values cannot exceed a depth of {self.max_depth}.") + if isinstance(value, float) and not math.isfinite(value): + raise ValueError("Filter and search parameter numbers must be finite.") + if isinstance(value, Decimal) and not value.is_finite(): + raise ValueError("Filter and search parameter numbers must be finite.") + if seen is None: + seen = set() + if isinstance(value, Mapping): + mapping = cast(Mapping[Any, Any], value) + if id(mapping) in seen: + raise ValueError("Search parameter values cannot contain cycles.") + seen.add(id(mapping)) + try: + for item in mapping.values(): + self.validate(item, seen=seen, depth=depth + 1) + finally: + seen.remove(id(mapping)) + elif _is_non_string_sequence(value): + sequence = cast(Sequence[Any], value) + if id(sequence) in seen: + raise ValueError("Search parameter values cannot contain cycles.") + seen.add(id(sequence)) + try: + for item in sequence: + self.validate(item, seen=seen, depth=depth + 1) + finally: + seen.remove(id(sequence)) + + +def _validate_param_data(value: Any) -> None: + _ParamDataValidator().validate(value) + + +def validate_param_value(param: Param, value: Any) -> Any: + """Validate one parameter value with native type and constraint checks.""" + _validate_param_data(value) + if not _matches_param_type(value, param.value_type): + raise TypeError(f"Search parameter '{param.name}' does not match {param.value_type}.") + if isinstance(value, int | float) and not isinstance(value, bool): + if param.minimum is not None and value < param.minimum: + raise ValueError(f"Search parameter '{param.name}' must be at least {param.minimum}.") + if param.maximum is not None and value > param.maximum: + raise ValueError(f"Search parameter '{param.name}' must be at most {param.maximum}.") + if isinstance(value, str | Sequence | Mapping): + sized_value = cast(str | Sequence[Any] | Mapping[Any, Any], value) + if param.min_length is not None and len(sized_value) < param.min_length: + raise ValueError(f"Search parameter '{param.name}' is shorter than {param.min_length}.") + if param.max_length is not None and len(sized_value) > param.max_length: + raise ValueError(f"Search parameter '{param.name}' is longer than {param.max_length}.") + return cast(Any, value) + + +@experimental(feature_id=ExperimentalFeature.VECTOR_STORES) +@dataclass(slots=True, init=False) +class Filter: + """Describe one data-only vector store filter. + + A ``Param`` must be the complete value of a leaf. In a search tool, + ``Filter("description", "contains_text", + Param("text", str | None, default=None, omit_if_none=True))`` is omitted + when ``text`` is absent or explicitly ``None`` (JSON ``null``). + ``omit_if_none=True`` requires both a nullable parameter type and an + explicit ``default=None``. Non-None arguments use normal operator + semantics; strings such as ``"*"`` are not special omission values. + + Omission removes this leaf from its group rather than making it match + every record. Remaining AND/OR children still apply; a NOT group is + removed if its child is removed. Empty groups are removed recursively, + and removing the whole tree means search receives no filter. + + With ``omit_if_none=False`` (the default), a supplied ``None`` is validated + as a value, not omitted. A literal ``Filter(..., value=None)`` does not + opt into omission either; use ``is_null`` to test for a null field. + + Nested parameters are forbidden in collection members and mapping keys as + well as mapping values. Structural depth/node limits apply during parameter + inspection and before operation snapshots are copied; oversized inputs may + therefore fail at construction as well as at operation time. + """ + + field_name: str + operator: FilterOperator + value: Any + + def __init__(self, field_name: str, operator: FilterOperator, value: Any = None) -> None: + """Initialize a filter. + + Args: + field_name: The logical model field, optionally followed by a provider-supported path. + operator: A standard operator or a namespaced provider operator. + value: The structured value consumed by the operator. + + Raises: + TypeError: If the field name or operator is not a string, or an operand has an invalid shape. + ValueError: If the field name or operator is invalid, an operator that takes no value receives one, + an operator that requires a value receives ``None``, ``between`` does not receive two boundaries, + a ``Param`` is nested inside a larger value, or structural limits are exceeded. + """ + if not isinstance(field_name, str): + raise TypeError("Filter field_name must be a string.") + if not isinstance(operator, str): + raise TypeError("Filter operator must be a string.") + _validate_name(field_name, kind="Filter field name", allow_path=True) + if operator not in _STANDARD_FILTER_OPERATORS and not _PROVIDER_OPERATOR_PATTERN.fullmatch(operator): + raise ValueError( + f"Unknown filter operator '{operator}'. Provider-specific operators must use a namespaced name." + ) + self.field_name = field_name + self.operator = operator + self.value = value + _validate_filter_value_shape(self, allow_params=True) + + +@experimental(feature_id=ExperimentalFeature.VECTOR_STORES) +@dataclass(slots=True, init=False) +class FilterGroup: + """Combine vector store filters with explicit boolean semantics. + + Search tools resolve parameters before evaluating the group. An absent + optional parameter without a default removes its leaf. A parameter such + as ``Param("text", str | None, default=None, omit_if_none=True)`` also + removes its leaf for an explicit ``None`` (JSON ``null``). This opt-in + requires a nullable type and an explicit ``default=None``. + + After omission, ``"and"`` requires all remaining children to match, and + ``"or"`` requires any remaining child to match. For example, combining + ``Filter("rating", "gte", 4)`` with an omitted text filter leaves only + the rating condition in either group. The omitted leaf is not replaced + with ``True``, which would make an OR group match every record. + + A ``"not"`` group negates its remaining child; if that child is removed, + the NOT group is removed too. Any group left with no children is removed + recursively. If the whole tree disappears, search receives no filter; + other search options still apply. Fixed children are never omitted. + + With ``omit_if_none=False`` (the default), explicit ``None`` values retain + normal type and operator validation rather than removing a child. + """ + + operator: FilterGroupOperator + filters: tuple[Filter | FilterGroup, ...] + + def __init__(self, operator: FilterGroupOperator, filters: Sequence[Filter | FilterGroup]) -> None: + """Initialize a filter group. + + Args: + operator: ``"and"``, ``"or"``, or unary ``"not"``. + filters: The filters combined by the operator. + + Raises: + TypeError: If the operator or filters have unsupported types. + ValueError: If the operator or number of filters is invalid, including structural limits. + """ + if not isinstance(operator, str): + raise TypeError("Filter group operator must be a string.") + if operator not in _GROUP_OPERATORS: + raise ValueError(f"Unknown filter group operator '{operator}'.") + if not _is_non_string_sequence(filters): + raise TypeError("FilterGroup filters must be a sequence.") + resolved_filters = tuple(islice(filters, _MAX_FILTER_NODES)) + if len(resolved_filters) >= _MAX_FILTER_NODES: + raise ValueError(f"Filters cannot contain more than {_MAX_FILTER_NODES} nodes.") + if not resolved_filters: + raise ValueError("FilterGroup requires at least one filter.") + if operator == "not" and len(resolved_filters) != 1: + raise ValueError("A 'not' FilterGroup requires exactly one filter.") + if any(not isinstance(item, Filter | FilterGroup) for item in resolved_filters): + raise TypeError("FilterGroup entries must be Filter or FilterGroup instances.") + self.operator = operator + self.filters = resolved_filters + + +FilterExpression: TypeAlias = Filter | FilterGroup + + +def _snapshot_filter(filter_: FilterExpression, *, active: set[int]) -> FilterExpression: + if id(filter_) in active: + raise ValueError("Filter expressions cannot contain cycles.") + active.add(id(filter_)) + try: + if isinstance(filter_, Filter): + value = ( + _snapshot_filter(filter_.value, active=active) + if isinstance(filter_.value, Filter | FilterGroup) + else deepcopy(filter_.value) + ) + return Filter(filter_.field_name, filter_.operator, value) + if isinstance(filter_, FilterGroup): + return FilterGroup( + filter_.operator, + tuple(_snapshot_filter(item, active=active) for item in filter_.filters), + ) + finally: + active.remove(id(filter_)) + raise TypeError("filter must be a Filter or FilterGroup.") + + +def snapshot_filter(filter_: FilterExpression) -> FilterExpression: + """Bound and validate the filter structure before copying it for one operation.""" + validate_filter(filter_, allow_params=True) + return _snapshot_filter(filter_, active=set()) + + +def _value_contains_param(value: Any) -> bool: + active: set[int] = set() + node_count = 0 + + def visit(value: Any, depth: int) -> bool: + nonlocal node_count + node_count += 1 + if node_count > _MAX_FILTER_NODES: + raise ValueError(f"Filters cannot contain more than {_MAX_FILTER_NODES} nodes.") + if depth > _MAX_FILTER_DEPTH: + raise ValueError(f"Filter values cannot exceed a depth of {_MAX_FILTER_DEPTH}.") + if isinstance(value, Param): + return True + if isinstance(value, (str, bytes, bytearray)) or not isinstance(value, Filter | FilterGroup | Collection): + return False + identity = id(cast(object, value)) + if identity in active: + raise ValueError("Filter values cannot contain cycles.") + active.add(identity) + try: + if isinstance(value, Filter): + return visit(value.value, depth + 1) + if isinstance(value, FilterGroup): + return any(visit(item, depth + 1) for item in value.filters) + if isinstance(value, Mapping): + return any( + (not isinstance(key, str) and visit(key, depth + 1)) or visit(item, depth + 1) + for key, item in cast(Mapping[Any, Any], value).items() + ) + return any(visit(item, depth + 1) for item in cast(Collection[Any], value)) + finally: + active.remove(identity) + + return visit(value, 1) + + +def _validate_filter_value_shape(filter_: Filter, *, allow_params: bool) -> None: + value = filter_.value + has_param = _value_contains_param(value) + if has_param and not isinstance(value, Param): + raise ValueError("Param must be the entire Filter value, not nested inside a collection or mapping.") + if has_param and not allow_params: + raise ValueError("Param references must be resolved before searching.") + if filter_.operator not in _STANDARD_FILTER_OPERATORS: + return + if filter_.operator in _NO_VALUE_OPERATORS: + if value is not None: + raise ValueError(f"Filter operator '{filter_.operator}' does not accept a value.") + return + if value is None: + raise ValueError(f"Filter operator '{filter_.operator}' requires a value.") + if filter_.operator in _TEXT_VALUE_OPERATORS and not has_param: + require_filter_string(value) + if filter_.operator not in _SEQUENCE_VALUE_OPERATORS or has_param: + return + if not _is_non_string_sequence(value): + raise TypeError(f"Filter operator '{filter_.operator}' requires a sequence value.") + if filter_.operator == "between" and len(cast(Sequence[Any], value)) != 2: + raise ValueError("'between' requires exactly two boundary values.") + + +def iter_filter_params(value: Any) -> tuple[Param, ...]: + if isinstance(value, Param): + return (value,) + if isinstance(value, Filter): + filter_value: Any = value.value + return (filter_value,) if isinstance(filter_value, Param) else () + if isinstance(value, FilterGroup): + return tuple(param for item in value.filters for param in iter_filter_params(item)) + return () + + +def _resolve_param_value( + value: Any, + arguments: Mapping[str, Any], +) -> Any: + if isinstance(value, Param): + if value.name in arguments: + resolved = arguments[value.name] + elif value.has_default: + resolved = value.default + elif value.required: + raise TypeError(f"Missing required search parameter '{value.name}'.") + else: + return _OMIT_FILTER + return _OMIT_FILTER if value.omit_if_none and resolved is None else deepcopy(resolved) + return deepcopy(value) + + +def resolve_filter_params( + filter_: FilterExpression, + arguments: Mapping[str, Any], +) -> FilterExpression | None: + if isinstance(filter_, Filter): + value = _resolve_param_value(filter_.value, arguments) + if value is _OMIT_FILTER: + return None + return Filter(filter_.field_name, filter_.operator, value) + resolved_filters = tuple( + resolved for item in filter_.filters if (resolved := resolve_filter_params(item, arguments)) is not None + ) + if not resolved_filters: + return None + if filter_.operator == "not" and len(resolved_filters) != 1: + return None + return FilterGroup(filter_.operator, resolved_filters) + + +@dataclass(slots=True) +class _FilterValidator: + field_names: Collection[str] | None + allow_params: bool + max_depth: int + max_nodes: int + node_count: int = 0 + + def validate_value(self, value: Any, *, depth: int, active: set[int]) -> None: + if depth > self.max_depth: + raise ValueError(f"Filter values cannot exceed a depth of {self.max_depth}.") + if isinstance(value, float) and not math.isfinite(value): + raise ValueError("Filter numbers must be finite.") + if isinstance(value, Decimal) and not value.is_finite(): + raise ValueError("Filter numbers must be finite.") + if isinstance(value, Param): + raise ValueError("Param must be the entire Filter value, not nested inside a collection or mapping.") + if isinstance(value, Filter | FilterGroup): + self.validate_expression(value, depth=depth, relative_fields=True, active=active) + return + if isinstance(value, Mapping): + mapping = cast(Mapping[Any, Any], value) + if id(mapping) in active: + raise ValueError("Filter values cannot contain cycles.") + active.add(id(mapping)) + try: + for key, item in mapping.items(): + if not isinstance(key, str): + raise TypeError("Filter mapping keys must be strings.") + self.count_node() + self.validate_value(item, depth=depth + 1, active=active) + finally: + active.remove(id(mapping)) + return + if isinstance(value, Collection) and not isinstance(value, (str, bytes, bytearray)): + collection = cast(Collection[Any], value) + if id(collection) in active: + raise ValueError("Filter values cannot contain cycles.") + active.add(id(collection)) + try: + for item in collection: + self.count_node() + self.validate_value(item, depth=depth + 1, active=active) + finally: + active.remove(id(collection)) + + def validate_expression( + self, + expression: FilterExpression, + *, + depth: int, + relative_fields: bool, + active: set[int], + ) -> None: + if depth > self.max_depth: + raise ValueError(f"Filters cannot exceed a depth of {self.max_depth}.") + if id(expression) in active: + raise ValueError("Filter expressions cannot contain cycles.") + active.add(id(expression)) + try: + self.count_node() + if isinstance(expression, Filter): + if ( + not relative_fields + and self.field_names is not None + and expression.field_name.split(".", maxsplit=1)[0] not in self.field_names + ): + raise ValueError( + f"Filter field '{expression.field_name}' is not part of the vector store definition." + ) + if not isinstance(expression.value, Param): + self.validate_value(expression.value, depth=depth + 1, active=active) + _validate_filter_value_shape(expression, allow_params=self.allow_params) + return + if not isinstance(expression, FilterGroup): + raise TypeError("filter must be a Filter or FilterGroup.") + for item in expression.filters: + self.validate_expression( + item, + depth=depth + 1, + relative_fields=relative_fields, + active=active, + ) + finally: + active.remove(id(expression)) + + def count_node(self) -> None: + self.node_count += 1 + if self.node_count > self.max_nodes: + raise ValueError(f"Filters cannot contain more than {self.max_nodes} nodes.") + + +def validate_filter( + filter_: FilterExpression, + *, + field_names: Collection[str] | None = None, + allow_params: bool = False, + max_depth: int = _MAX_FILTER_DEPTH, + max_nodes: int = _MAX_FILTER_NODES, +) -> None: + _FilterValidator( + field_names=field_names, + allow_params=allow_params, + max_depth=max_depth, + max_nodes=max_nodes, + ).validate_expression(filter_, depth=1, relative_fields=False, active=set()) diff --git a/python/packages/core/agent_framework/_vectors.py b/python/packages/core/agent_framework/_vectors.py index 761cc3a5bb2..3ed815e0911 100644 --- a/python/packages/core/agent_framework/_vectors.py +++ b/python/packages/core/agent_framework/_vectors.py @@ -6,10 +6,11 @@ import operator from abc import ABC, abstractmethod -from ast import AST, Lambda, NodeVisitor, expr, parse from collections.abc import AsyncIterable, AsyncIterator, Callable, Mapping, Sequence +from copy import deepcopy from dataclasses import dataclass, is_dataclass, replace -from inspect import Parameter, getsource, signature +from dataclasses import field as dataclass_field +from inspect import Parameter, signature from types import UnionType from typing import ( Annotated, @@ -39,30 +40,42 @@ from ._telemetry import FeatureIndex, mark_feature_used from ._tools import FunctionTool from ._types import Content, EmbeddingGenerationOptions +from ._vector_filters import ( + FilterExpression, + Param, + iter_filter_params, + param_schema, + resolve_filter_params, + snapshot_filter, + validate_filter, + validate_param_value, +) from .exceptions import IntegrationException, IntegrationInvalidResponseException ModelT = TypeVar("ModelT", default=Any) KeyT = TypeVar("KeyT", default=Any) -FilterT = TypeVar("FilterT") ResultT = TypeVar("ResultT") DecoratedModelT = TypeVar("DecoratedModelT") SearchType: TypeAlias = Literal["vector", "keyword_hybrid"] FieldTypes: TypeAlias = Literal["key", "vector", "data"] -IndexKind: TypeAlias = Literal["hnsw", "flat", "ivf_flat", "disk_ann", "quantized_flat", "dynamic", "default"] -DistanceFunction: TypeAlias = Literal[ - "cosine_similarity", - "cosine_distance", - "dot_prod", - "euclidean_distance", - "euclidean_squared_distance", - "manhattan", - "hamming", - "DEFAULT", -] -Vector: TypeAlias = Sequence[float | int] -RecordFilter: TypeAlias = Callable[[Any], bool] | str -RecordFilters: TypeAlias = RecordFilter | Sequence[RecordFilter] +IndexKind: TypeAlias = Literal["hnsw", "flat", "ivf_flat", "disk_ann", "quantized_flat", "dynamic", "default"] | str +DistanceFunction: TypeAlias = ( + Literal[ + "cosine_similarity", + "cosine_distance", + "dot_prod", + "negative_dot_prod", + "euclidean_distance", + "euclidean_squared_distance", + "manhattan", + "hamming", + "DEFAULT", + ] + | str +) +Vector: TypeAlias = Sequence[float | int] | bytes | bytearray +GenerateVectors: TypeAlias = bool | list[str] | tuple[str, ...] EmbeddingClient: TypeAlias = SupportsGetEmbeddings[Any, Any, Any] VectorModelEncoder: TypeAlias = Callable[[Any], Mapping[str, Any]] VectorModelDecoder: TypeAlias = Callable[[Mapping[str, Any]], Any] @@ -71,31 +84,11 @@ _DEFAULT_SEARCH_TOOL_DESCRIPTION: Final[str] = ( "Perform a vector search for data in a vector store using the provided query." ) -_INDEX_KINDS: Final[tuple[str, ...]] = ( - "hnsw", - "flat", - "ivf_flat", - "disk_ann", - "quantized_flat", - "dynamic", - "default", -) -_DISTANCE_FUNCTIONS: Final[tuple[str, ...]] = ( - "cosine_similarity", - "cosine_distance", - "dot_prod", - "euclidean_distance", - "euclidean_squared_distance", - "manhattan", - "hamming", - "DEFAULT", -) - - DISTANCE_FUNCTION_DIRECTION_HELPER: Final[Mapping[DistanceFunction, Callable[[float | int, float | int], bool]]] = { "cosine_similarity": operator.ge, "cosine_distance": operator.le, "dot_prod": operator.ge, + "negative_dot_prod": operator.le, "euclidean_distance": operator.le, "euclidean_squared_distance": operator.le, "manhattan": operator.le, @@ -103,9 +96,18 @@ } +def _copy_provider_annotations(value: Mapping[str, Any] | None) -> dict[str, Any]: + annotations = dict(value or {}) + if any(not isinstance(key, str) for key in annotations): + raise TypeError("Provider annotation keys must be strings.") + return deepcopy(annotations) + + def _msgspec_enc_hook(value: Any) -> Any: if isinstance(value, BaseModel): return value.model_dump() + if isinstance(value, Mapping): + return dict(cast(Mapping[Any, Any], value)) to_list = getattr(value, "tolist", None) if callable(to_list): return to_list() @@ -115,20 +117,48 @@ def _msgspec_enc_hook(value: Any) -> Any: def _normalize_vector(value: Any) -> Vector: - if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + if isinstance(value, bytes): + return value + if isinstance(value, bytearray): + return bytes(value) + if isinstance(value, Sequence) and not isinstance(value, str): return cast(Vector, value) to_list = getattr(value, "tolist", None) if callable(to_list): converted = to_list() - if isinstance(converted, Sequence) and not isinstance(converted, (str, bytes, bytearray)): + if isinstance(converted, bytearray): + return bytes(converted) + if isinstance(converted, Sequence) and not isinstance(converted, str): return cast(Vector, converted) raise TypeError("The embedding client returned an unsupported vector type.") +def _validate_vector_dimensions( + vector: Any, + field: VectorStoreField, + *, + record_index: int | None = None, +) -> None: + """Check dense sequence length without inspecting elements or interpreting native encodings.""" + # Normalized vectors do not need the more expensive generic sequence check. + if type(vector) not in (list, tuple) and not _is_non_string_sequence(vector): + return + actual_dimensions = len(vector) + if actual_dimensions != field.dimensions: + context = "Query" if record_index is None else f"Record at index {record_index}," + raise ValueError( + f"{context} vector field '{field.name}' expects {field.dimensions} dimensions; got {actual_dimensions}." + ) + + @experimental(feature_id=ExperimentalFeature.VECTOR_STORES) @dataclass(frozen=True, slots=True, init=False) class VectorStoreField: - """Describe one field in a vector store model.""" + """Describe one field in a vector store model. + + Vector ``dimensions`` is the expected length of materialized dense sequences. + Binary bytes and non-sequence provider-native representations remain connector-validated. + """ field_type: FieldTypes name: str @@ -140,6 +170,8 @@ class VectorStoreField: index_kind: IndexKind | None distance_function: DistanceFunction | None embedding_generator: EmbeddingClient | None + is_auto_generated: bool + provider_annotations: dict[str, Any] = dataclass_field(hash=False) @overload def __init__( @@ -149,6 +181,8 @@ def __init__( name: str | None = None, type_: str | None = None, storage_name: str | None = None, + is_auto_generated: bool = False, + provider_annotations: Mapping[str, Any] | None = None, ) -> None: """Initialize a key field. @@ -157,6 +191,8 @@ def __init__( name: The model field name. The decorator supplies this when omitted. type_: The scalar type name used by the backing store. storage_name: The field name used by the backing store. + is_auto_generated: Whether the backing store generates missing key values. + provider_annotations: Mutable provider-specific configuration, copied when the field is created. """ ... @@ -170,6 +206,7 @@ def __init__( storage_name: str | None = None, is_indexed: bool | None = None, is_full_text_indexed: bool | None = None, + provider_annotations: Mapping[str, Any] | None = None, ) -> None: """Initialize a data field with optional indexing. @@ -180,6 +217,7 @@ def __init__( storage_name: The field name used by the backing store. is_indexed: Whether the field should be indexed. is_full_text_indexed: Whether the field should have a full-text index. + provider_annotations: Mutable provider-specific configuration, copied when the field is created. """ ... @@ -195,6 +233,7 @@ def __init__( index_kind: IndexKind | None = None, distance_function: DistanceFunction | None = None, embedding_generator: EmbeddingClient | None = None, + provider_annotations: Mapping[str, Any] | None = None, ) -> None: """Initialize a vector field with required dimensions. @@ -207,6 +246,7 @@ def __init__( index_kind: The vector index kind. distance_function: The vector distance function. embedding_generator: An optional client used to generate this field's embeddings. + provider_annotations: Mutable provider-specific configuration, copied when the field is created. Raises: ValueError: If dimensions or vector options are invalid. @@ -226,6 +266,8 @@ def __init__( index_kind: IndexKind | None = None, distance_function: DistanceFunction | None = None, embedding_generator: EmbeddingClient | None = None, + is_auto_generated: bool = False, + provider_annotations: Mapping[str, Any] | None = None, ) -> None: """Initialize a vector store field. @@ -240,12 +282,17 @@ def __init__( index_kind: The vector index kind. distance_function: The vector distance function. embedding_generator: An optional client used to generate this field's embeddings. + is_auto_generated: Whether a key field is generated by the backing store when missing. + provider_annotations: Mutable provider-specific configuration, copied when the field is created. Raises: + TypeError: If ``is_auto_generated`` is not a boolean. ValueError: If field options are invalid. """ if field_type not in ("key", "vector", "data"): raise ValueError(f"Unknown vector store field type '{field_type}'.") + if not isinstance(is_auto_generated, bool): + raise TypeError("Vector is_auto_generated must be a boolean.") resolved_dimensions: int | None = None resolved_index_kind: IndexKind | None = None resolved_distance_function: DistanceFunction | None = None @@ -253,16 +300,18 @@ def __init__( if field_type == "vector": if dimensions is None or dimensions <= 0: raise ValueError("Vector fields must specify a positive number of dimensions.") - if index_kind is not None and index_kind not in _INDEX_KINDS: - raise ValueError(f"Unknown vector index kind '{index_kind}'.") - if distance_function is not None and distance_function not in _DISTANCE_FUNCTIONS: - raise ValueError(f"Unknown vector distance function '{distance_function}'.") + if index_kind is not None and not isinstance(index_kind, str): + raise TypeError("Vector index_kind must be a string.") + if distance_function is not None and not isinstance(distance_function, str): + raise TypeError("Vector distance_function must be a string.") resolved_dimensions = dimensions resolved_index_kind = index_kind or "default" resolved_distance_function = distance_function or "DEFAULT" resolved_embedding_generator = embedding_generator elif any(value is not None for value in (dimensions, index_kind, distance_function, embedding_generator)): raise ValueError("Vector-only options can only be set on vector fields.") + if field_type != "key" and is_auto_generated: + raise ValueError("Only key fields can be auto-generated.") object.__setattr__(self, "field_type", field_type) object.__setattr__(self, "name", name or "") @@ -274,6 +323,8 @@ def __init__( object.__setattr__(self, "index_kind", resolved_index_kind) object.__setattr__(self, "distance_function", resolved_distance_function) object.__setattr__(self, "embedding_generator", resolved_embedding_generator) + object.__setattr__(self, "is_auto_generated", is_auto_generated) + object.__setattr__(self, "provider_annotations", _copy_provider_annotations(provider_annotations)) @experimental(feature_id=ExperimentalFeature.VECTOR_STORES) @@ -325,6 +376,12 @@ def _validate(self) -> str: storage_names = [field.storage_name or field.name for field in self.fields] if len(storage_names) != len(set(storage_names)): raise ValueError("Vector store field storage names must be unique.") + name_set = set(names) + if any( + field.storage_name is not None and field.storage_name != field.name and field.storage_name in name_set + for field in self.fields + ): + raise ValueError("A vector store field storage name cannot match another field's model name.") key_fields = [field for field in self.fields if field.field_type == "key"] if len(key_fields) != 1: @@ -371,14 +428,19 @@ def data_field_names(self) -> list[str]: """Get the data field names.""" return [field.name for field in self.data_fields] + def try_get_field(self, field_name: str) -> VectorStoreField | None: + """Get a field by model or storage name.""" + model_field = next((field for field in self.fields if field.name == field_name), None) + if model_field is not None: + return model_field + return next((field for field in self.fields if field.storage_name == field_name), None) + def try_get_vector_field(self, field_name: str | None = None) -> VectorStoreField | None: """Get a vector field by model or storage name, defaulting to the first vector field.""" if field_name is None: return self.vector_fields[0] if self.vector_fields else None - return next( - (field for field in self.vector_fields if field.name == field_name or field.storage_name == field_name), - None, - ) + field = self.try_get_field(field_name) + return field if field is not None and field.field_type == "vector" else None def get_names(self, *, include_vector_fields: bool = True, include_key_field: bool = True) -> list[str]: """Get selected model field names.""" @@ -416,7 +478,12 @@ def _default_vector_model_encoder(record_type: type[Any]) -> VectorModelEncoder: def encode(value: Any) -> Mapping[str, Any]: if not isinstance(value, record_type): raise TypeError(f"Expected {record_type.__name__}, got {type(value).__name__}.") - converted = msgspec.to_builtins(value, str_keys=True, enc_hook=_msgspec_enc_hook) + converted = msgspec.to_builtins( + value, + str_keys=True, + builtin_types=(bytes, bytearray), + enc_hook=_msgspec_enc_hook, + ) if not isinstance(converted, Mapping): raise TypeError(f"Vector model {record_type.__name__!r} must serialize to a mapping.") return cast(Mapping[str, Any], converted) @@ -484,6 +551,14 @@ def register_vectorstoremodel( "Vector fields omitted by include_vectors=False must declare defaults when using the default decoder. " f"Add defaults or supply a custom decoder for: {', '.join(required_vector_fields)}." ) + if is_dataclass(record_type) or issubclass(record_type, msgspec.Struct): + try: + msgspec.inspect.type_info(record_type) + except TypeError as exc: + raise ValueError( + f"Vector model {record_type.__name__!r} is not supported by the default msgspec decoder: {exc}. " + "Supply a custom decoder." + ) from exc resolved_encoder = ( cast(VectorModelEncoder, encoder) if encoder is not None else _default_vector_model_encoder(record_type) ) @@ -533,6 +608,9 @@ def _infer_type_name(annotation: Any, *, vector: bool) -> str | None: if origin is not None and args: candidate = next((arg for arg in args if arg is not Ellipsis), candidate) return getattr(candidate, "__name__", str(candidate)) + binary_candidate = next((candidate for candidate in candidates if candidate in (bytes, bytearray)), None) + if binary_candidate is not None: + return "bytes" candidate = candidates[0] if candidates else annotation origin = get_origin(candidate) return getattr(origin or candidate, "__name__", None) @@ -806,15 +884,21 @@ async def serialize( self, records: ModelT | Sequence[ModelT], *, - generate_vectors: bool = True, + generate_vectors: GenerateVectors = True, context: Mapping[str, Any] | None = None, ) -> Any: """Serialize one or more application records for the backing store. + After optional embedding generation, materialized dense sequence lengths + are checked against their fields' dimensions for the entire batch before + connector conversion. This does not inspect vector elements. Null vectors, + source text, binary payloads, and non-sequence provider-native values are + left to the connector. + Args: records: One application record or a sequence of records. - generate_vectors: Whether to generate vector values, overwriting any supplied values. When ``False``, - supplied values are preserved. + generate_vectors: Whether to generate all vector fields, preserve all supplied values, or generate only + the vector fields named in a sequence. Generated values overwrite supplied values. context: Connector-specific serialization context. Raises: @@ -827,8 +911,13 @@ async def serialize( input_records = list(cast(Sequence[ModelT], records)) if is_batch else [cast(ModelT, records)] dict_records = [self._serialize_record_to_dict(record) for record in input_records] - if generate_vectors: - await self._add_vectors_to_records(dict_records) + vector_fields = self._resolve_vector_fields_to_generate(generate_vectors) + if vector_fields: + await self._add_vectors_to_records(dict_records, vector_fields=vector_fields) + dimension_fields = tuple((field.storage_name or field.name, field) for field in self.definition.vector_fields) + for record_index, record in enumerate(dict_records): + for storage_name, field in dimension_fields: + _validate_vector_dimensions(record.get(storage_name), field, record_index=record_index) store_models = list(self._serialize_dicts_to_store_models(dict_records, context=context)) if len(store_models) != len(dict_records): @@ -852,7 +941,12 @@ def _serialize_record_to_dict(self, record: ModelT) -> dict[str, Any]: @staticmethod def _to_builtin_mapping(record: Any) -> Mapping[str, Any]: - converted = msgspec.to_builtins(record, str_keys=True, enc_hook=_msgspec_enc_hook) + converted = msgspec.to_builtins( + record, + str_keys=True, + builtin_types=(bytes, bytearray), + enc_hook=_msgspec_enc_hook, + ) if not isinstance(converted, Mapping): raise TypeError("Vector records must serialize to mappings.") return cast(Mapping[str, Any], converted) @@ -864,14 +958,47 @@ def _serialize_mapping_to_store(self, source: Mapping[str, Any]) -> dict[str, An value = source[field.name] elif field.storage_name is not None and field.storage_name in source: value = source[field.storage_name] + elif field.field_type == "key" and field.is_auto_generated: + continue else: raise ValueError(f"Record is missing vector store field '{field.name}'.") + if field.field_type == "key" and field.is_auto_generated and value is None: + continue + if field.field_type == "vector" and isinstance(value, bytearray): + value = bytes(value) serialized[field.storage_name or field.name] = value return serialized - async def _add_vectors_to_records(self, records: Sequence[dict[str, Any]]) -> None: + def _resolve_vector_fields_to_generate( + self, + generate_vectors: GenerateVectors, + ) -> tuple[VectorStoreField, ...]: + if isinstance(generate_vectors, bool): + return tuple(self.definition.vector_fields) if generate_vectors else () + if not _is_non_string_sequence(generate_vectors) or any( + not isinstance(field_name, str) for field_name in generate_vectors + ): + raise TypeError("generate_vectors must be a boolean or a sequence of vector field names.") + field_names = list(cast(Sequence[str], generate_vectors)) + if len(field_names) != len(set(field_names)): + raise ValueError("generate_vectors field names must be unique.") + vector_fields = {field.name: field for field in self.definition.vector_fields} + unknown = sorted(set(field_names) - set(vector_fields)) + if unknown: + raise ValueError(f"Unknown vector field(s) in generate_vectors: {', '.join(unknown)}.") + selected = set(field_names) + return tuple(field for field in self.definition.vector_fields if field.name in selected) + + async def _add_vectors_to_records( + self, + records: Sequence[dict[str, Any]], + *, + vector_fields: Sequence[VectorStoreField], + ) -> None: + if not records: + return field_generators: list[tuple[VectorStoreField, EmbeddingClient]] = [] - for field in self.definition.vector_fields: + for field in vector_fields: embedding_generator = field.embedding_generator or self.embedding_generator if embedding_generator is None: raise ValueError( @@ -1029,6 +1156,7 @@ async def _inner_get( self, *, keys: Sequence[KeyT] | None = None, + filter: FilterExpression | None = None, top: int = 10, skip: int = 0, order_by: Mapping[str, bool] | None = None, @@ -1052,15 +1180,26 @@ async def upsert( self, records: Sequence[ModelT], *, - generate_vectors: bool = True, + generate_vectors: GenerateVectors = True, operation_options: Mapping[str, Any] | None = None, ) -> Sequence[KeyT]: """Upsert a batch of records. + Dense sequence lengths are checked after optional embedding generation, + before connector conversion or writes. A dimension mismatch rejects the + whole batch at this boundary. Binary and non-sequence provider-native + representations remain connector-validated. + + A connector may partially persist a batch before reporting an error; the + abstraction does not guarantee rollback or atomicity. Retrying records + with stable application-provided keys should be idempotent when the + backing store supports ordinary upsert semantics. Retrying records whose + keys are generated by the store may create duplicates. + Args: records: A sequence of models. - generate_vectors: Whether to generate vector values, overwriting any supplied values. When ``False``, - supplied values are preserved. + generate_vectors: Whether to generate all vector fields, preserve all supplied values, or generate only + the vector fields named in a sequence. Generated values overwrite supplied values. operation_options: Store-specific operation options. Returns: @@ -1079,7 +1218,7 @@ async def upsert( serialized = await self.serialize(records, generate_vectors=generate_vectors) store_records = list(serialized) if _is_non_string_sequence(serialized) else [serialized] keys = list(await self._inner_upsert(store_records, operation_options=operation_options)) - except (TypeError, ValueError): + except (TypeError, ValueError, NotImplementedError): raise except IntegrationException: raise @@ -1097,6 +1236,7 @@ async def get( self, keys: Sequence[KeyT] | None = None, *, + filter: FilterExpression | None = None, top: int = 10, skip: int = 0, order_by: Mapping[str, bool] | None = None, @@ -1107,6 +1247,7 @@ async def get( Args: keys: A sequence of keys, or ``None`` to list a page of records. + filter: A portable data-only filter used when listing records. top: The maximum number of records returned when listing. skip: The number of records skipped when listing. order_by: Field names mapped to ascending (``True``) or descending (``False``) order. @@ -1117,7 +1258,7 @@ async def get( A sequence of models. Keys that do not exist are omitted. Raises: - ValueError: If paging arguments are invalid. + ValueError: If paging arguments or filters are invalid, or keys and a filter are supplied together. TypeError: If keys or a returned record has an unsupported type. IntegrationException: If retrieval fails. """ @@ -1125,15 +1266,23 @@ async def get( _validate_paging(top=top, skip=skip) if keys is not None and not _is_non_string_sequence(keys): raise TypeError("keys must be a sequence.") + if keys is not None and filter is not None: + raise ValueError("keys and filter are alternate retrieval modes and cannot be combined.") + operation_filter = snapshot_filter(filter) if filter is not None else None + if operation_filter is not None: + validate_filter(operation_filter, field_names=self.definition.names) try: records = await self._inner_get( keys=keys, + filter=operation_filter, top=top, skip=skip, order_by=order_by, include_vectors=include_vectors, operation_options=operation_options, ) + except (TypeError, ValueError, NotImplementedError): + raise except IntegrationException: raise except Exception as exc: @@ -1251,18 +1400,14 @@ async def _inner_ensure_collection_deleted( ... -class _LambdaVisitor(NodeVisitor, Generic[FilterT]): - def __init__(self, lambda_parser: Callable[[expr], FilterT]) -> None: - self.lambda_parser = lambda_parser - self.output_filters: list[FilterT] = [] - - def visit_Lambda(self, node: Lambda) -> None: - self.output_filters.append(self.lambda_parser(node.body)) - - @experimental(feature_id=ExperimentalFeature.VECTOR_STORES) class BaseVectorSearch(_VectorStoreRecordHandler[KeyT, ModelT], ABC): - """Base class for vector and keyword-hybrid search.""" + """Base class for vector and keyword-hybrid search. + + Core validates portable request structure and deserializes connector results. + Connectors own scoring, filter execution, score thresholds, and paging. + Returned scores and results are not re-filtered by core. + """ supported_search_types: ClassVar[set[SearchType]] = {"vector"} @@ -1271,7 +1416,7 @@ async def _inner_search( self, *, search_type: SearchType, - filter: Any | list[Any] | None = None, + filter: FilterExpression | None = None, values: Any | None = None, vector: Vector | None = None, top: int = 3, @@ -1282,7 +1427,14 @@ async def _inner_search( score_threshold: float | None = None, operation_options: Mapping[str, Any] | None = None, ) -> SearchResults[Any]: - """Execute a search and return raw connector results.""" + """Execute a search and return raw connector results. + + Apply filters and score thresholds natively in the backing store where + supported. Otherwise implement an explicit connector-local fallback or + raise ``NotImplementedError``; do not silently ignore these options. + The connector owns score units, comparison direction, default metrics, + and coordinating filtering with paging. Core does not post-filter results. + """ ... @abstractmethod @@ -1295,11 +1447,6 @@ def _get_score_from_result(self, result: Any) -> float | None: """Extract a score from one raw search result.""" ... - @abstractmethod - def _lambda_parser(self, node: AST) -> Any: - """Translate one lambda expression body into a store filter.""" - ... - @overload async def search( self, @@ -1307,7 +1454,7 @@ async def search( *, search_type: SearchType = "vector", vector: Vector | None = None, - filter: RecordFilters | None = None, + filter: FilterExpression | None = None, top: int = 3, skip: int = 0, include_vectors: bool = False, @@ -1318,18 +1465,22 @@ async def search( ) -> SearchResults[SearchResponse[ModelT]]: """Search from a value, optionally with a precomputed vector. + Materialized dense sequence length must match the selected vector field's + dimensions before connector dispatch. Binary bytes and non-sequence + provider-native formats remain connector-validated. + Args: values: The value to search for or vectorize. search_type: Whether to perform vector or keyword-hybrid search. vector: An optional precomputed query vector. - filter: One or more lambda filters. + filter: A portable data-only filter. top: The maximum number of results. skip: The number of results to skip. include_vectors: Whether returned records include vector fields. vector_property_name: The vector field used for search. additional_property_name: The data field used for keyword-hybrid search. - score_threshold: The minimum similarity or maximum distance accepted. - Results without scores remain included. + score_threshold: An optional cutoff interpreted and enforced by the connector. + Score units, comparison direction, and default metrics are connector-specific. operation_options: Store-specific operation options. Returns: @@ -1348,7 +1499,7 @@ async def search( *, search_type: Literal["vector"] = "vector", vector: Vector, - filter: RecordFilters | None = None, + filter: FilterExpression | None = None, top: int = 3, skip: int = 0, include_vectors: bool = False, @@ -1359,17 +1510,21 @@ async def search( ) -> SearchResults[SearchResponse[ModelT]]: """Search from a required precomputed vector. + Dense sequence length must match the selected vector field's dimensions + before connector dispatch. Binary bytes and non-sequence provider-native + formats remain connector-validated. + Args: search_type: The vector search type. vector: The precomputed query vector. - filter: One or more lambda filters. + filter: A portable data-only filter. top: The maximum number of results. skip: The number of results to skip. include_vectors: Whether returned records include vector fields. vector_property_name: The vector field used for search. additional_property_name: The data field used for keyword-hybrid search. - score_threshold: The minimum similarity or maximum distance accepted. - Results without scores remain included. + score_threshold: An optional cutoff interpreted and enforced by the connector. + Score units, comparison direction, and default metrics are connector-specific. operation_options: Store-specific operation options. Returns: @@ -1388,7 +1543,7 @@ async def search( *, search_type: SearchType = "vector", vector: Vector | None = None, - filter: RecordFilters | None = None, + filter: FilterExpression | None = None, top: int = 3, skip: int = 0, include_vectors: bool = False, @@ -1399,18 +1554,29 @@ async def search( ) -> SearchResults[SearchResponse[ModelT]]: """Search the vector store. + Supplied or locally generated dense sequence length is checked against the + selected vector field's dimensions before connector dispatch, even for + empty collections. This does not inspect elements or convert native + payloads. Binary and non-sequence provider-native formats remain + connector-validated; provider-side vectorization still receives ``values`` + and no vector. + + Filters and score thresholds are passed to the connector for execution, + normally in the backing store. Core deserializes returned records without + applying another threshold comparison or changing returned scores. + Args: values: The value to search for or vectorize. search_type: Whether to perform vector or keyword-hybrid search. vector: A precomputed query vector. - filter: One or more lambda filters. + filter: A portable data-only filter. top: The maximum number of results. skip: The number of results to skip. include_vectors: Whether returned records include vector fields. vector_property_name: The vector field used for search. additional_property_name: The data field used for keyword-hybrid search. - score_threshold: The minimum similarity or maximum distance accepted. - Results without scores remain included. + score_threshold: An optional cutoff interpreted and enforced by the connector. + Score units, comparison direction, and default metrics are connector-specific. operation_options: Store-specific operation options. Returns: @@ -1432,21 +1598,27 @@ async def search( raise ValueError("Keyword-hybrid search requires values.") _validate_paging(top=top, skip=skip) + operation_filter = snapshot_filter(filter) if filter is not None else None + if operation_filter is not None: + validate_filter(operation_filter, field_names=self.definition.names) try: - self._validate_score_threshold( - score_threshold=score_threshold, - vector_property_name=vector_property_name, - ) resolved_vector = vector if resolved_vector is None and values is not None: resolved_vector = await self._generate_vector_from_values( values, vector_property_name=vector_property_name, ) - translated_filter = self._build_filter(filter) + if resolved_vector is not None: + vector_field = self.definition.try_get_vector_field(vector_property_name) + if vector_field is None and vector_property_name is not None: + raise ValueError( + f"Vector field '{vector_property_name}' was not found in the collection definition." + ) + if vector_field is not None: + _validate_vector_dimensions(resolved_vector, vector_field) raw_results = await self._inner_search( search_type=search_type, - filter=translated_filter, + filter=operation_filter, values=values, vector=resolved_vector, top=top, @@ -1461,32 +1633,16 @@ async def search( self._get_search_results_from_results( raw_results.results, include_vectors=include_vectors, - vector_property_name=vector_property_name, - score_threshold=score_threshold, ), metadata=raw_results.metadata, ) - except (TypeError, ValueError): + except (TypeError, ValueError, NotImplementedError): raise except IntegrationException: raise except Exception as exc: raise IntegrationException(f"Vector search failed: {exc}") from exc - def _validate_score_threshold( - self, - *, - score_threshold: float | None, - vector_property_name: str | None, - ) -> None: - if score_threshold is None: - return - vector_field = self.definition.try_get_vector_field(vector_property_name) - if vector_field is None: - raise ValueError("A score threshold requires a vector field.") - if vector_field.distance_function == "DEFAULT": - raise ValueError("A score threshold requires an explicit distance function on the vector field.") - async def _generate_vector_from_values( self, values: Any, @@ -1512,37 +1668,11 @@ async def _generate_vector_from_values( generated_vector = embeddings[0].vector return _normalize_vector(generated_vector) - def _build_filter(self, search_filter: RecordFilters | None) -> Any | list[Any] | None: - """Translate lambda filters with the connector's AST parser.""" - if not search_filter: - return None - filters: list[RecordFilter] - if _is_non_string_sequence(search_filter) and not callable(search_filter): - filters = cast(list[RecordFilter], list(search_filter)) - else: - filters = [cast(RecordFilter, search_filter)] - visitor = _LambdaVisitor(self._lambda_parser) - try: - for filter_item in filters: - source = ( - filter_item - if isinstance(filter_item, str) - else getsource(cast(Callable[..., Any], filter_item)).strip() - ) - visitor.visit(parse(source)) - except (OSError, SyntaxError, TypeError) as exc: - raise ValueError(f"Unable to parse vector search filter: {exc}") from exc - if not visitor.output_filters: - raise ValueError("No lambda expression was found in the vector search filter.") - return visitor.output_filters[0] if len(visitor.output_filters) == 1 else visitor.output_filters - def _get_search_results_from_results( self, results: AsyncIterable[Any] | Sequence[Any], *, include_vectors: bool, - vector_property_name: str | None, - score_threshold: float | None, ) -> AsyncIterable[SearchResponse[ModelT]]: """Convert raw connector results into deserialized search responses.""" @@ -1561,12 +1691,6 @@ async def generate() -> AsyncIterator[SearchResponse[ModelT]]: "A search result must deserialize to exactly one record." ) score = self._get_score_from_result(result) - if not self._meets_score_threshold( - score, - score_threshold=score_threshold, - vector_property_name=vector_property_name, - ): - continue yield SearchResponse(record=cast(ModelT, record), score=score) except IntegrationException: raise @@ -1581,26 +1705,6 @@ async def generate() -> AsyncIterator[SearchResponse[ModelT]]: return generate() - def _meets_score_threshold( - self, - score: float | None, - *, - score_threshold: float | None, - vector_property_name: str | None, - ) -> bool: - """Apply a threshold when a result includes a comparable score. - - Results without scores remain included because the threshold cannot be - evaluated for them. - """ - if score_threshold is None or score is None: - return True - vector_field = self.definition.try_get_vector_field(vector_property_name) - if vector_field is None or vector_field.distance_function is None: - return True - comparison = DISTANCE_FUNCTION_DIRECTION_HELPER.get(vector_field.distance_function) - return comparison(score, score_threshold) if comparison is not None else True - @runtime_checkable @experimental(feature_id=ExperimentalFeature.VECTOR_STORES) @@ -1615,23 +1719,24 @@ async def upsert( self, records: Sequence[ModelT], *, - generate_vectors: bool = True, + generate_vectors: GenerateVectors = True, operation_options: Mapping[str, Any] | None = None, ) -> Sequence[KeyT]: - """Upsert a batch of records, generating embeddings by default.""" + """Upsert a batch, which may partially succeed, generating embeddings by default.""" ... async def get( self, keys: Sequence[KeyT] | None = None, *, + filter: FilterExpression | None = None, top: int = 10, skip: int = 0, order_by: Mapping[str, bool] | None = None, include_vectors: bool = False, operation_options: Mapping[str, Any] | None = None, ) -> Sequence[ModelT]: - """Get records by keys or list a page of records, excluding vectors by default.""" + """Get records by keys or filter, or list a page of records, excluding vectors by default.""" ... async def delete( @@ -1647,7 +1752,12 @@ async def delete( @runtime_checkable @experimental(feature_id=ExperimentalFeature.VECTOR_STORES) class SupportsVectorSearch(Protocol[ModelT]): - """Protocol for vector and keyword-hybrid search.""" + """Protocol for vector and keyword-hybrid search. + + Implementations own scoring, filter execution, score thresholds, and paging. + Execute these in the backing store where supported, otherwise use an explicit + local fallback or reject unsupported options. + """ @overload async def search( @@ -1656,7 +1766,7 @@ async def search( *, search_type: SearchType = "vector", vector: Vector | None = None, - filter: RecordFilters | None = None, + filter: FilterExpression | None = None, top: int = 3, skip: int = 0, include_vectors: bool = False, @@ -1671,14 +1781,14 @@ async def search( values: The value to search for or vectorize. search_type: Whether to perform vector or keyword-hybrid search. vector: An optional precomputed query vector. - filter: One or more lambda filters. + filter: A portable data-only filter. top: The maximum number of results. skip: The number of results to skip. include_vectors: Whether returned records include vector fields. vector_property_name: The vector field used for search. additional_property_name: The data field used for keyword-hybrid search. - score_threshold: The minimum similarity or maximum distance accepted. - Results without scores remain included. + score_threshold: An optional cutoff interpreted and enforced by the connector. + Score units, comparison direction, and default metrics are connector-specific. operation_options: Store-specific operation options. Returns: @@ -1697,7 +1807,7 @@ async def search( *, search_type: Literal["vector"] = "vector", vector: Vector, - filter: RecordFilters | None = None, + filter: FilterExpression | None = None, top: int = 3, skip: int = 0, include_vectors: bool = False, @@ -1711,14 +1821,14 @@ async def search( Args: search_type: The vector search type. vector: The precomputed query vector. - filter: One or more lambda filters. + filter: A portable data-only filter. top: The maximum number of results. skip: The number of results to skip. include_vectors: Whether returned records include vector fields. vector_property_name: The vector field used for search. additional_property_name: The data field used for keyword-hybrid search. - score_threshold: The minimum similarity or maximum distance accepted. - Results without scores remain included. + score_threshold: An optional cutoff interpreted and enforced by the connector. + Score units, comparison direction, and default metrics are connector-specific. operation_options: Store-specific operation options. Returns: @@ -1740,11 +1850,9 @@ def create_vector_search_tool( description: str = _DEFAULT_SEARCH_TOOL_DESCRIPTION, approval_mode: Literal["always_require", "never_require"] = "never_require", search_type: SearchType = "vector", - parameters: type[BaseModel] | Mapping[str, Any] | None = None, - top: int = 5, - skip: int = 0, - filter: RecordFilters | None = None, - filter_mapper: Callable[[RecordFilters | None, Mapping[str, Any]], RecordFilters | None] | None = None, + top: int | Param = 5, + skip: int | Param = 0, + filter: FilterExpression | None = None, result_mapper: Callable[[SearchResponse[ModelT]], str | Content | Sequence[Content]] | None = None, ) -> FunctionTool: """Create an agent-usable tool backed by vector search. @@ -1755,51 +1863,82 @@ def create_vector_search_tool( description: The tool description shown to the model. approval_mode: Whether the tool requires approval before invocation. search_type: Whether the tool performs vector or keyword-hybrid search. - parameters: A Pydantic model or JSON schema declaring the tool parameters. - It must declare ``query`` as a required string. A custom schema can - expose ``top`` and ``skip`` as integers with finite ``maximum`` values; - additional fields are passed to ``filter_mapper``. - top: The default result limit and the maximum when ``parameters`` does not expose ``top``. - skip: The default offset and the maximum when ``parameters`` does not expose ``skip``. - filter: A fixed filter applied to each tool invocation. - filter_mapper: Maps additional declared tool arguments to search filters. - The default creates equality filters for each additional argument. + top: A fixed result limit or a bounded model-set parameter. + skip: A fixed result offset or a bounded model-set parameter. + filter: A fixed filter that may contain model-set ``Param`` values. + A nullable ``Param`` with ``default=None`` and ``omit_if_none=True`` removes + its leaf for an absent or null argument. Remaining group children still apply; + empty groups are removed recursively. See ``FilterGroup`` for details. result_mapper: Maps each search response to text or one or more multimodal content items. Returns: - A function tool with only a ``query`` parameter by default. Custom parameters can expose - ``top``, ``skip``, and fields mapped into filters by ``filter_mapper``. + A function tool with ``query`` and any parameters discovered in ``filter``, ``top``, or ``skip``. Raises: - ValueError: If parameters or paging limits are invalid. + TypeError: If a parameter annotation or default is invalid. + ValueError: If filters, parameters, or paging limits are invalid. NotImplementedError: If the search type is unsupported. """ - _validate_paging(top=top, skip=skip) - map_filter = filter_mapper or _default_search_filter_mapper + if isinstance(top, bool) or isinstance(skip, bool): + raise TypeError("top and skip must be integers or Param instances.") + if not isinstance(top, int | Param) or not isinstance(skip, int | Param): + raise TypeError("top and skip must be integers or Param instances.") + if isinstance(top, int): + _validate_paging(top=top, skip=0) + if isinstance(skip, int): + _validate_paging(top=1, skip=skip) + map_result = result_mapper or _default_search_result_mapper - input_model = parameters if parameters is not None else _default_search_tool_parameters() - max_top, max_skip = _validate_search_tool_parameters( - input_model, - default_top=top, - default_skip=skip, + configured_filter = snapshot_filter(filter) if filter is not None else None + input_schema, param_definitions = _create_search_tool_input_schema( + filter=configured_filter, + top=top, + skip=skip, ) + _validate_search_tool_paging_param("top", top, input_schema) + _validate_search_tool_paging_param("skip", skip, input_schema) + definition = getattr(search, "definition", None) + if configured_filter is not None and isinstance(definition, VectorStoreCollectionDefinition): + validate_filter(configured_filter, field_names=definition.names, allow_params=True) async def search_tool(**arguments: Any) -> list[Content]: - query = arguments.pop("query") + unexpected = sorted(set(arguments) - set(cast(Mapping[str, Any], input_schema["properties"]))) + if unexpected: + raise TypeError(f"Unexpected argument(s) for '{name}': {', '.join(unexpected)}") + missing = sorted(set(cast(Sequence[str], input_schema["required"])) - set(arguments)) + if missing: + raise TypeError(f"Missing required argument(s) for '{name}': {', '.join(missing)}") + query = arguments.get("query") if not isinstance(query, str): raise TypeError("The search tool 'query' argument must be a string.") - invocation_top = arguments.pop("top", top) - invocation_skip = arguments.pop("skip", skip) + validated_arguments = { + parameter_name: validate_param_value(param, arguments[parameter_name]) + for parameter_name, param in param_definitions.items() + if parameter_name in arguments + } + resolved_arguments = { + **{ + parameter_name: param.default + for parameter_name, param in param_definitions.items() + if param.has_default + }, + **validated_arguments, + } + invocation_top = _resolve_search_tool_option("top", top, resolved_arguments) + invocation_skip = _resolve_search_tool_option("skip", skip, resolved_arguments) _validate_paging(top=invocation_top, skip=invocation_skip) - if invocation_top > max_top: - raise ValueError(f"top must not exceed the configured maximum of {max_top}.") - if invocation_skip > max_skip: - raise ValueError(f"skip must not exceed the configured maximum of {max_skip}.") - dynamic_filter = map_filter(filter, arguments) + resolved_filter = ( + resolve_filter_params(configured_filter, resolved_arguments) if configured_filter is not None else None + ) + if resolved_filter is not None: + validate_filter( + resolved_filter, + field_names=definition.names if isinstance(definition, VectorStoreCollectionDefinition) else None, + ) results = await search.search( query, search_type=search_type, - filter=dynamic_filter, + filter=resolved_filter, top=invocation_top, skip=invocation_skip, ) @@ -1823,7 +1962,7 @@ async def search_tool(**arguments: Any) -> list[Content]: description=description, approval_mode=approval_mode, func=search_tool, - input_model=input_model, + input_model=input_schema, ) @@ -1842,78 +1981,81 @@ async def _as_async_iterable( yield value -def _default_search_tool_parameters() -> dict[str, Any]: - return { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "The query to search for.", - }, +def _create_search_tool_input_schema( + *, + filter: FilterExpression | None, + top: int | Param, + skip: int | Param, +) -> tuple[dict[str, Any], dict[str, Param]]: + params = [*iter_filter_params(filter)] if filter is not None else [] + params.extend(option for option in (top, skip) if isinstance(option, Param)) + definitions: dict[str, Param] = {} + + for param in params: + existing = definitions.get(param.name) + if existing is not None: + if existing != param: + raise ValueError(f"Search parameter '{param.name}' has conflicting declarations.") + continue + definitions[param.name] = param + if param.has_default: + validate_param_value(param, param.default) + + properties = { + "query": { + "type": "string", + "description": "The query to search for.", }, - "required": ["query"], - "additionalProperties": False, + **{name: param_schema(param) for name, param in definitions.items()}, } + required = ["query", *(name for name, param in definitions.items() if param.required)] + return ( + { + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": False, + }, + definitions, + ) -def _validate_search_tool_parameters( - parameters: type[BaseModel] | Mapping[str, Any], - *, - default_top: int, - default_skip: int, -) -> tuple[int, int]: - schema: Mapping[str, Any] = parameters.model_json_schema() if isinstance(parameters, type) else parameters - raw_properties = schema.get("properties") - if not isinstance(raw_properties, Mapping): - raise ValueError("Search tool parameters must define object properties.") - properties = cast(Mapping[str, Any], raw_properties) - query_schema = properties.get("query") - required = schema.get("required") - query_type = cast(Mapping[str, Any], query_schema).get("type") if isinstance(query_schema, Mapping) else None - if ( - not isinstance(query_schema, Mapping) - or query_type != "string" - or not _is_non_string_sequence(required) - or "query" not in required - ): - raise ValueError("Search tool parameters must define 'query' as a required string.") - - limits = {"top": default_top, "skip": default_skip} - for name, minimum in (("top", 1), ("skip", 0)): - parameter_schema = properties.get(name) - if parameter_schema is None: - continue - if not isinstance(parameter_schema, Mapping): - raise ValueError(f"Search tool parameter '{name}' must be an integer.") - typed_parameter_schema = cast(Mapping[str, Any], parameter_schema) - if typed_parameter_schema.get("type") != "integer": - raise ValueError(f"Search tool parameter '{name}' must be an integer.") - maximum = typed_parameter_schema.get("maximum") - if not isinstance(maximum, int) or isinstance(maximum, bool) or maximum < minimum: - raise ValueError(f"Search tool parameter '{name}' must declare an integer maximum of at least {minimum}.") - configured_default = default_top if name == "top" else default_skip - if configured_default > maximum: - raise ValueError(f"Configured {name}={configured_default} exceeds the parameter maximum of {maximum}.") - limits[name] = maximum - return limits["top"], limits["skip"] - - -def _default_search_filter_mapper( - search_filter: RecordFilters | None, +def _validate_search_tool_paging_param( + option_name: Literal["top", "skip"], + option: int | Param, + input_schema: Mapping[str, Any], +) -> None: + if isinstance(option, int): + return + if option.omit_if_none: + raise ValueError(f"The {option_name} Param does not support omit_if_none.") + if not option.required and not option.has_default: + raise ValueError(f"A model-set {option_name} Param must be required or declare a default.") + parameter_schema = cast(Mapping[str, Any], cast(Mapping[str, Any], input_schema["properties"])[option.name]) + minimum = parameter_schema.get("minimum") + maximum = parameter_schema.get("maximum") + required_minimum = 1 if option_name == "top" else 0 + if parameter_schema.get("type") != "integer": + raise ValueError(f"The {option_name} Param must use an integer annotation.") + if not isinstance(minimum, int | float) or isinstance(minimum, bool) or minimum < required_minimum: + raise ValueError(f"The {option_name} Param must declare a minimum of at least {required_minimum}.") + if not isinstance(maximum, int) or isinstance(maximum, bool) or maximum < required_minimum: + raise ValueError(f"The {option_name} Param must declare a finite integer maximum.") + + +def _resolve_search_tool_option( + option_name: Literal["top", "skip"], + option: int | Param, arguments: Mapping[str, Any], -) -> RecordFilters | None: - dynamic_filters: list[RecordFilter] = [] - for name, value in arguments.items(): - if not name.isidentifier(): - raise ValueError(f"Search tool parameter '{name}' cannot be mapped to a model field.") - dynamic_filters.append(f"lambda record: record.{name} == {value!r}") - if not dynamic_filters: - return search_filter - if search_filter is None: - return dynamic_filters - if _is_non_string_sequence(search_filter) and not callable(search_filter): - return [*cast(Sequence[RecordFilter], search_filter), *dynamic_filters] - return [cast(RecordFilter, search_filter), *dynamic_filters] +) -> int: + if isinstance(option, int): + return option + if option.name not in arguments: + raise TypeError(f"Missing search parameter '{option.name}' for {option_name}.") + value = arguments[option.name] + if not isinstance(value, int) or isinstance(value, bool): + raise TypeError(f"Search parameter '{option.name}' for {option_name} must be an integer.") + return value def _default_search_result_mapper(response: SearchResponse[Any]) -> str: diff --git a/python/packages/core/tests/core/test_in_memory.py b/python/packages/core/tests/core/test_in_memory.py new file mode 100644 index 00000000000..ca72fdcef61 --- /dev/null +++ b/python/packages/core/tests/core/test_in_memory.py @@ -0,0 +1,811 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import ast +import inspect +import math +import warnings +from collections.abc import Iterator, Sequence +from dataclasses import dataclass +from typing import Annotated, Any, cast + +import pytest + +from agent_framework import ( + BaseEmbeddingClient, + DistanceFunction, + Embedding, + EmbeddingGenerationOptions, + Filter, + FilterGroup, + GeneratedEmbeddings, + InMemoryCollection, + InMemoryStore, + Param, + VectorStoreCollectionDefinition, + VectorStoreField, + create_vector_search_tool, + register_vectorstoremodel, + vectorstoremodel, +) +from agent_framework import _in_memory as in_memory_module +from agent_framework._feature_stage import ExperimentalWarning +from agent_framework.exceptions import IntegrationException + +pytestmark = pytest.mark.filterwarnings("ignore::agent_framework._feature_stage.ExperimentalWarning") + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", ExperimentalWarning) + + @vectorstoremodel(collection_name="documents") + @dataclass + class Document: + id: Annotated[str, VectorStoreField("key")] + text: Annotated[str, VectorStoreField("data")] + category: Annotated[str, VectorStoreField("data")] + rating: Annotated[int, VectorStoreField("data")] + tags: Annotated[list[str], VectorStoreField("data")] + optional: Annotated[str | None, VectorStoreField("data")] + vector: Annotated[ + list[float] | None, + VectorStoreField("vector", dimensions=2, distance_function="cosine_similarity"), + ] = None + + +DOCUMENTS = ( + Document("one", "Luxury hotel", "travel", 5, ["wifi", "pool"], "featured", [1.0, 0.0]), + Document("two", "Budget hostel", "work", 3, ["wifi"], None, [0.0, 1.0]), +) + + +class QueryEmbeddingClient(BaseEmbeddingClient[str, list[float], EmbeddingGenerationOptions]): + async def get_embeddings( + self, + values: Sequence[str], + *, + options: EmbeddingGenerationOptions | None = None, + ) -> GeneratedEmbeddings[list[float], EmbeddingGenerationOptions]: + return GeneratedEmbeddings([Embedding(vector=[1.0, 0.0]) for _ in values], options=options) + + +async def _create_collection() -> InMemoryCollection[str, Document]: + collection = InMemoryCollection(Document) + await collection.ensure_collection_exists() + await collection.upsert(DOCUMENTS, generate_vectors=False) + return collection + + +async def _result_ids( + collection: InMemoryCollection[str, Document], + filter_: Filter | FilterGroup, +) -> list[str]: + results = await collection.search(vector=[1.0, 0.0], filter=filter_, top=10) + return [result["record"].id async for result in results] + + +async def test_in_memory_store_shares_collection_state_and_lifecycle() -> None: + store = InMemoryStore() + first = store.get_collection(Document) + second = store.get_collection(Document) + + assert not await first.collection_exists() + assert await store.list_collection_names() == [] + + await first.ensure_collection_exists() + await first.upsert([DOCUMENTS[0]], generate_vectors=False) + + assert await store.list_collection_names() == ["documents"] + assert await second.get(["one"], include_vectors=True) == [DOCUMENTS[0]] + + await store.ensure_collection_deleted("documents") + assert not await first.collection_exists() + with pytest.raises(IntegrationException, match="does not exist"): + await second.get(["one"]) + + +def test_in_memory_store_rejects_conflicting_collection_definitions() -> None: + store = InMemoryStore() + store.get_collection(Document) + other_definition = VectorStoreCollectionDefinition( + [ + VectorStoreField("key", name="other_id"), + VectorStoreField("vector", name="vector", dimensions=2), + ], + collection_name="documents", + ) + + with pytest.raises(ValueError, match="another definition"): + store.get_collection(dict, definition=other_definition) + + +async def test_in_memory_collection_requires_creation() -> None: + collection: InMemoryCollection[str, Document] = InMemoryCollection(Document) + + with pytest.raises(IntegrationException, match="does not exist"): + await collection.get() + + +async def test_in_memory_crud_listing_ordering_and_defensive_copies() -> None: + collection = await _create_collection() + + assert await collection.get(["one"]) == [ + Document("one", "Luxury hotel", "travel", 5, ["wifi", "pool"], "featured"), + ] + assert await collection.get(order_by={"rating": False}, top=1) == [ + Document("one", "Luxury hotel", "travel", 5, ["wifi", "pool"], "featured"), + ] + assert await collection.get(order_by={"rating": True}, skip=1, top=1) == [ + Document("one", "Luxury hotel", "travel", 5, ["wifi", "pool"], "featured"), + ] + assert await collection.get(order_by={"optional": True}) == [ + Document("one", "Luxury hotel", "travel", 5, ["wifi", "pool"], "featured"), + Document("two", "Budget hostel", "work", 3, ["wifi"], None), + ] + with pytest.raises(ValueError, match="not part of the vector store definition"): + await collection.get(order_by={"missing": True}) + with pytest.raises(TypeError, match="must be a boolean"): + await collection.get(order_by=cast(Any, {"rating": "descending"})) + + fetched = (await collection.get(["one"], include_vectors=True))[0] + cast(list[float], fetched.vector)[0] = 99.0 + assert (await collection.get(["one"], include_vectors=True))[0].vector == [1.0, 0.0] + + await collection.delete(["one", "missing"]) + assert await collection.get(["one"]) == [] + + +async def test_in_memory_get_filters_before_ordering_and_paging() -> None: + collection = await _create_collection() + + results = await collection.get( + filter=Filter("category", "eq", "travel"), + order_by={"rating": False}, + top=1, + ) + + assert results == [Document("one", "Luxury hotel", "travel", 5, ["wifi", "pool"], "featured")] + + +async def test_in_memory_generates_missing_string_keys() -> None: + definition = VectorStoreCollectionDefinition( + [ + VectorStoreField("key", name="id", type_="str", is_auto_generated=True), + VectorStoreField("data", name="text"), + VectorStoreField("vector", name="vector", dimensions=2), + ], + collection_name="generated-keys", + ) + collection: InMemoryCollection[str, dict[str, Any]] = InMemoryCollection(dict, definition=definition) + await collection.ensure_collection_exists() + + keys = await collection.upsert( + [{"text": "generated", "vector": [1.0, 0.0]}], + generate_vectors=False, + ) + + assert len(keys) == 1 + assert isinstance(keys[0], str) + assert await collection.get(keys, include_vectors=True) == [ + {"id": keys[0], "text": "generated", "vector": [1.0, 0.0]} + ] + + +@pytest.mark.parametrize( + ("filter_", "expected"), + [ + (Filter("category", "eq", "travel"), ["one"]), + (Filter("category", "ne", "travel"), ["two"]), + (Filter("rating", "gt", 4), ["one"]), + (Filter("rating", "gte", 5), ["one"]), + (Filter("rating", "lt", 4), ["two"]), + (Filter("rating", "lte", 3), ["two"]), + (Filter("rating", "between", (4, 5)), ["one"]), + (Filter("category", "in", ("travel",)), ["one"]), + (Filter("category", "not_in", ("travel",)), ["two"]), + (Filter("optional", "is_null"), ["two"]), + (Filter("optional", "is_not_null"), ["one"]), + (Filter("optional", "exists"), ["one", "two"]), + (Filter("optional", "starts_with", "feat"), ["one"]), + (Filter("tags", "contains", "pool"), ["one"]), + (Filter("tags", "contains_any", ("pool", "missing")), ["one"]), + (Filter("tags", "contains_all", ("wifi", "pool")), ["one"]), + (Filter("text", "starts_with", "Luxury"), ["one"]), + (Filter("text", "ends_with", "hostel"), ["two"]), + (Filter("text", "contains_text", "hotel"), ["one"]), + (Filter("rating", "eq", True), []), + (Filter("rating", "in", (True,)), []), + (FilterGroup("not", (Filter("category", "eq", "travel"),)), ["two"]), + ], +) +async def test_in_memory_filter_operators( + filter_: Filter | FilterGroup, + expected: list[str], +) -> None: + assert await _result_ids(await _create_collection(), filter_) == expected + + +@pytest.mark.parametrize( + ("operator", "operand", "expected"), + [ + ("eq", [[1]], {"integer", "float"}), + ("eq", [[True]], {"boolean"}), + ("ne", [[1]], {"boolean", "zero"}), + ("ne", [[True]], {"integer", "float", "zero"}), + ("in", ([[1]],), {"integer", "float"}), + ("in", ([[True]],), {"boolean"}), + ("not_in", ([[1]],), {"boolean", "zero"}), + ("not_in", ([[True]],), {"integer", "float", "zero"}), + ("contains", [1], {"integer", "float"}), + ("contains", [True], {"boolean"}), + ("contains_any", ([1],), {"integer", "float"}), + ("contains_any", ([True],), {"boolean"}), + ("contains_all", ([1],), {"integer", "float"}), + ("contains_all", ([True],), {"boolean"}), + ], +) +async def test_in_memory_filters_distinguish_nested_booleans_from_numbers( + operator: str, + operand: Any, + expected: set[str], +) -> None: + definition = VectorStoreCollectionDefinition( + [ + VectorStoreField("key", name="id"), + VectorStoreField("data", name="value"), + VectorStoreField("vector", name="vector", dimensions=2), + ], + collection_name="nested-values", + ) + collection: InMemoryCollection[str, dict[str, Any]] = InMemoryCollection(dict, definition=definition) + await collection.ensure_collection_exists() + await collection.upsert( + [ + {"id": "integer", "value": [[1]], "vector": [1.0, 0.0]}, + {"id": "float", "value": [[1.0]], "vector": [1.0, 0.0]}, + {"id": "boolean", "value": [[True]], "vector": [1.0, 0.0]}, + {"id": "zero", "value": [[0]], "vector": [1.0, 0.0]}, + ], + generate_vectors=False, + ) + filter_ = Filter("value", operator, operand) + + records = await collection.get(filter=filter_) + assert {record["id"] for record in records} == expected + results = await collection.search(vector=[1.0, 0.0], filter=filter_) + assert {result["record"]["id"] async for result in results} == expected + + +async def test_in_memory_filter_groups_use_explicit_and_or_semantics() -> None: + collection = await _create_collection() + tenant_scope = Filter("category", "eq", "travel") + model_filter = Filter("id", "eq", "two") + + assert await _result_ids(collection, FilterGroup("and", (tenant_scope, model_filter))) == [] + assert await _result_ids(collection, FilterGroup("or", (tenant_scope, model_filter))) == ["one", "two"] + + +async def test_in_memory_search_tool_resolves_params_without_weakening_fixed_filter() -> None: + collection: InMemoryCollection[str, Document] = InMemoryCollection( + Document, + embedding_generator=QueryEmbeddingClient(), + ) + await collection.ensure_collection_exists() + await collection.upsert(DOCUMENTS, generate_vectors=False) + record_id = Param("record_id", str) + tool = create_vector_search_tool( + collection, + filter=FilterGroup( + "and", + ( + Filter("category", "eq", "travel"), + Filter("id", "eq", record_id), + ), + ), + ) + + assert len(await tool(query="hotel", record_id="one")) == 1 + assert await tool(query="hotel", record_id="two") == [] + assert len(await tool(query="hotel")) == 1 + + +async def test_in_memory_search_tool_omits_null_text_condition_in_and_group() -> None: + definition = VectorStoreCollectionDefinition( + [ + VectorStoreField("key", name="id"), + VectorStoreField("data", name="description"), + VectorStoreField("data", name="rating"), + VectorStoreField("vector", name="vector", dimensions=2), + ], + collection_name="optional-text", + ) + collection: InMemoryCollection[str, dict[str, Any]] = InMemoryCollection( + dict, definition=definition, embedding_generator=QueryEmbeddingClient() + ) + await collection.ensure_collection_exists() + records = [ + {"id": "text", "rating": 5, "description": "Pool hotel"}, + {"id": "empty", "rating": 5, "description": ""}, + {"id": "star", "rating": 5, "description": "5* hotel"}, + {"id": "null", "rating": 5, "description": None}, + {"id": "low", "rating": 3, "description": "Pool hotel"}, + ] + await collection.upsert( + [{**record, "vector": [1.0, 0.0]} for record in records], + generate_vectors=False, + ) + tool = create_vector_search_tool( + collection, + top=10, + filter=FilterGroup( + "and", + ( + Filter("rating", "gte", 4), + Filter( + "description", + "contains_text", + Param("text", str | None, default=None, omit_if_none=True), + ), + ), + ), + result_mapper=lambda response: response["record"]["id"], + ) + + assert {item.text for item in await tool(query="hotel")} == {"text", "empty", "star", "null"} + assert {item.text for item in await tool(query="hotel", text=None)} == {"text", "empty", "star", "null"} + assert {item.text for item in await tool(query="hotel", text="Pool")} == {"text"} + assert {item.text for item in await tool(query="hotel", text="")} == {"text", "empty", "star"} + assert {item.text for item in await tool(query="hotel", text="*")} == {"star"} + + +@pytest.mark.parametrize( + ("distance_function", "expected_scores"), + [ + ("cosine_similarity", (1.0, 0.0)), + ("cosine_distance", (0.0, 1.0)), + ("dot_prod", (1.0, 0.0)), + ("negative_dot_prod", (-1.0, 0.0)), + ("euclidean_distance", (0.0, math.sqrt(2))), + ("euclidean_squared_distance", (0.0, 2.0)), + ("manhattan", (0.0, 2.0)), + ("hamming", (0.0, 1.0)), + ("DEFAULT", (0.0, 1.0)), + ], +) +async def test_in_memory_distance_functions( + distance_function: DistanceFunction, + expected_scores: tuple[float, float], +) -> None: + definition = VectorStoreCollectionDefinition( + [ + VectorStoreField("key", name="id"), + VectorStoreField( + "vector", + name="vector", + dimensions=2, + distance_function=distance_function, + ), + ], + collection_name=f"distance-{distance_function}", + ) + collection: InMemoryCollection[str, dict[str, Any]] = InMemoryCollection(dict, definition=definition) + await collection.ensure_collection_exists() + await collection.upsert( + [ + {"id": "same", "vector": [1.0, 0.0]}, + {"id": "other", "vector": [0.0, 1.0]}, + ], + generate_vectors=False, + ) + + results = await collection.search(vector=[1.0, 0.0], top=2) + responses = [result async for result in results] + + assert [response["record"]["id"] for response in responses] == ["same", "other"] + assert [cast(float, response["score"]) for response in responses] == pytest.approx(expected_scores) + assert results.metadata == {"in_memory_total_count": 2} + + +@pytest.mark.parametrize("scale", [1e308, 1e-308, 5e-324]) +@pytest.mark.parametrize("similarity", [1.0, 0.0, -1.0]) +@pytest.mark.parametrize("distance_function", ["cosine_similarity", "cosine_distance", "DEFAULT"]) +def test_cosine_scoring_handles_extreme_finite_magnitudes( + scale: float, similarity: float, distance_function: DistanceFunction +) -> None: + left = [scale, 0.0] + right = [scale, 0.0] if similarity == 1.0 else [-scale, 0.0] if similarity == -1.0 else [0.0, scale] + expected = similarity if distance_function == "cosine_similarity" else 1 - similarity + + assert in_memory_module._calculate_score(left, right, distance_function) == pytest.approx(expected) + + +async def test_large_opposing_vectors_are_not_misranked() -> None: + collection = InMemoryCollection(Document) + await collection.ensure_collection_exists() + await collection.upsert( + [ + Document("opposed", "text", "travel", 5, [], None, [-1e308, 0.0]), + Document("aligned", "text", "travel", 5, [], None, [1e308, 0.0]), + ], + generate_vectors=False, + ) + results = await collection.search(vector=[1e308, 0.0], score_threshold=0.5) + + assert [result["record"].id async for result in results] == ["aligned"] + + +@pytest.mark.parametrize( + ("right", "expected"), + [([1e-308, 1e-308], 1.0), ([-1e-308, -1e-308], -1.0), ([1e-308, -1e-308], 0.0)], +) +def test_cosine_scales_each_vector_independently(right: list[float], expected: float) -> None: + assert in_memory_module._calculate_score([1e308, 1e308], right, "cosine_similarity") == pytest.approx(expected) + + +async def test_non_finite_score_reports_the_record_context() -> None: + definition = VectorStoreCollectionDefinition( + [ + VectorStoreField("key", name="id"), + VectorStoreField("vector", name="vector", dimensions=2, distance_function="dot_prod"), + ], + collection_name="non-finite-score", + ) + collection: InMemoryCollection[str, dict[str, Any]] = InMemoryCollection(dict, definition=definition) + await collection.ensure_collection_exists() + await collection.upsert([{"id": "overflow", "vector": [1e308, 0.0]}], generate_vectors=False) + + with pytest.raises(ValueError, match="Record 'overflow'.*non-finite score"): + await collection.search(vector=[1e308, 0.0]) + + +@pytest.mark.parametrize( + ("distance_function", "left", "right"), + [ + ("dot_prod", [1e308, 0.0], [1e308, 0.0]), + ("dot_prod", [1e308, 1e308], [1e308, -1e308]), + ("negative_dot_prod", [1e308, 0.0], [1e308, 0.0]), + ("euclidean_distance", [1e308, 0.0], [-1e308, 0.0]), + ("euclidean_squared_distance", [1e200, 0.0], [0.0, 0.0]), + ("manhattan", [1e308, 0.0], [-1e308, 0.0]), + ], +) +def test_distance_functions_reject_non_finite_scores( + distance_function: DistanceFunction, left: list[float], right: list[float] +) -> None: + with pytest.raises(ValueError, match="non-finite score"): + in_memory_module._calculate_score(left, right, distance_function) + + +def test_distance_functions_do_not_compute_unnecessary_squares() -> None: + assert in_memory_module._calculate_score([1e200, 0], [0, 0], "euclidean_distance") == 1e200 + assert in_memory_module._calculate_score([1e308, 0], [-1e308, 0], "hamming") == 0.5 + + +async def test_hamming_scores_and_thresholds_use_mismatch_proportions() -> None: + definition = VectorStoreCollectionDefinition( + [ + VectorStoreField("key", name="id"), + VectorStoreField("vector", name="vector", dimensions=2, distance_function="hamming"), + ], + collection_name="normalized-hamming", + ) + collection: InMemoryCollection[str, dict[str, Any]] = InMemoryCollection(dict, definition=definition) + await collection.ensure_collection_exists() + await collection.upsert( + [ + {"id": "same", "vector": [0, 0]}, + {"id": "partial", "vector": [1, 0]}, + {"id": "different", "vector": [1, 1]}, + ], + generate_vectors=False, + ) + results = await collection.search(vector=[0, 0], score_threshold=0.5) + + assert [(result["record"]["id"], result["score"]) async for result in results] == [ + ("same", 0.0), + ("partial", 0.5), + ] + + +@pytest.mark.parametrize("contents", ["empty", "missing_vector", "filtered_out"]) +@pytest.mark.parametrize("score_threshold", [None, 0.5]) +async def test_unsupported_distance_is_rejected_independently_of_records( + contents: str, score_threshold: float | None +) -> None: + definition = VectorStoreCollectionDefinition( + [ + VectorStoreField("key", name="id"), + VectorStoreField("vector", name="vector", dimensions=2, distance_function="provider.unsupported"), + ], + collection_name="unsupported-distance", + ) + collection: InMemoryCollection[str, dict[str, Any]] = InMemoryCollection(dict, definition=definition) + await collection.ensure_collection_exists() + if contents != "empty": + await collection.upsert( + [{"id": "one", "vector": None if contents == "missing_vector" else [1.0, 0.0]}], + generate_vectors=False, + ) + with pytest.raises(NotImplementedError, match="provider.unsupported"): + await collection.search( + vector=[1.0, 0.0], + filter=Filter("id", "eq", "absent") if contents == "filtered_out" else None, + score_threshold=score_threshold, + ) + + +@pytest.mark.parametrize( + ("distance_function", "threshold"), + [("DEFAULT", 1.0), ("cosine_distance", 1.0), ("cosine_similarity", 0.0)], +) +async def test_in_memory_owns_filtering_and_thresholds_before_paging( + distance_function: DistanceFunction, threshold: float +) -> None: + definition = VectorStoreCollectionDefinition( + [ + VectorStoreField("key", name="id"), + VectorStoreField("data", name="category"), + VectorStoreField("vector", name="vector", dimensions=2, distance_function=distance_function), + ], + collection_name="threshold-paging", + ) + collection: InMemoryCollection[str, dict[str, Any]] = InMemoryCollection(dict, definition=definition) + await collection.ensure_collection_exists() + await collection.upsert( + [ + {"id": "threshold-excluded", "category": "keep", "vector": [-1.0, 0.0]}, + {"id": "filter-excluded", "category": "exclude", "vector": [1.0, 0.0]}, + {"id": "best", "category": "keep", "vector": [1.0, 0.0]}, + {"id": "boundary", "category": "keep", "vector": [0.0, 1.0]}, + ], + generate_vectors=False, + ) + + results = await collection.search( + vector=[1.0, 0.0], + filter=Filter("category", "eq", "keep"), + score_threshold=threshold, + skip=1, + top=1, + ) + + assert [(result["record"]["id"], result["score"]) async for result in results] == [("boundary", threshold)] + assert results.metadata == {"in_memory_total_count": 2} + + +@pytest.mark.parametrize("has_records", [False, True]) +async def test_in_memory_default_threshold_accepts_zero_distance(has_records: bool) -> None: + definition = VectorStoreCollectionDefinition( + [ + VectorStoreField("key", name="id"), + VectorStoreField("vector", name="vector", dimensions=2), + ], + collection_name="default-threshold", + ) + collection: InMemoryCollection[str, dict[str, Any]] = InMemoryCollection(dict, definition=definition) + await collection.ensure_collection_exists() + if has_records: + await collection.upsert( + [ + {"id": "same", "vector": [1.0, 0.0]}, + {"id": "different", "vector": [0.0, 1.0]}, + ], + generate_vectors=False, + ) + + results = await collection.search(vector=[1.0, 0.0], score_threshold=0.0) + + assert [(result["record"]["id"], result["score"]) async for result in results] == ( + [("same", 0.0)] if has_records else [] + ) + + +@pytest.mark.parametrize("operator", ["starts_with", "ends_with", "contains_text"]) +async def test_invalid_string_operand_is_rejected_for_empty_collection(operator: str) -> None: + collection = InMemoryCollection(Document) + await collection.ensure_collection_exists() + expression = Filter("text", operator, "valid") + expression.value = 1 + + with pytest.raises(TypeError, match="must be a string"): + await collection.search(vector=[1.0, 0.0], filter=expression) + + +@pytest.mark.parametrize("custom_codec", [False, True]) +async def test_stored_values_are_normalized_before_filter_comparisons(custom_codec: bool) -> None: + class ComparisonProbe: + def __init__(self) -> None: + self.value = 1 + + def __eq__(self, other: object) -> bool: + raise AssertionError("Custom equality must not execute during filtering.") + + class ComparisonList(list[Any]): + def __eq__(self, other: object) -> bool: + raise AssertionError("Custom list equality must not execute during filtering.") + + def __iter__(self) -> Iterator[Any]: + raise AssertionError("Custom iteration must not execute during filtering.") + + @dataclass + class EncodedRecord: + id: str + payload: Any + vector: list[float] | None = None + + definition = VectorStoreCollectionDefinition( + [ + VectorStoreField("key", name="id"), + VectorStoreField("data", name="payload"), + VectorStoreField("vector", name="vector", dimensions=2), + ], + collection_name="normalized-records", + ) + payload = ComparisonList([ComparisonProbe()]) + collection: InMemoryCollection[str, Any] + if custom_codec: + register_vectorstoremodel( + EncodedRecord, + definition=definition, + encoder=lambda record: {"id": record.id, "payload": record.payload, "vector": record.vector}, + ) + collection = InMemoryCollection(EncodedRecord) + await collection.ensure_collection_exists() + await collection.upsert([EncodedRecord("one", payload, [1.0, 0.0])], generate_vectors=False) + else: + collection = InMemoryCollection(dict, definition=definition) + await collection.ensure_collection_exists() + await collection.upsert([{"id": "one", "payload": payload, "vector": [1.0, 0.0]}], generate_vectors=False) + fetched = (await collection.get(["one"]))[0] + stored = fetched.payload if isinstance(fetched, EncodedRecord) else fetched["payload"] + + assert type(stored) is list + assert stored == [{"value": 1}] + assert await collection.get(filter=Filter("payload", "eq", [1])) == [] + assert await collection.get(filter=Filter("payload", "contains", 1)) == [] + + +async def test_in_memory_rejects_unsafe_or_unsupported_filters() -> None: + collection = await _create_collection() + + with pytest.raises(TypeError, match="Filter or FilterGroup"): + await collection.search(vector=[1.0, 0.0], filter=cast(Any, "lambda record: True")) + with pytest.raises(NotImplementedError, match="python.eval"): + await collection.search( + vector=[1.0, 0.0], + filter=Filter("text", "python.eval", "__import__('os').system('echo unsafe')"), + ) + with pytest.raises(NotImplementedError, match="nested"): + await collection.search(vector=[1.0, 0.0], filter=Filter("text.value", "eq", "unsafe")) + with pytest.raises(ValueError, match="more than 64 nodes"): + await collection.search(vector=[1.0, 0.0], filter=Filter("id", "in", tuple(str(i) for i in range(65)))) + with pytest.raises(ValueError, match="must be resolved"): + await collection.search(vector=[1.0, 0.0], filter=Filter("category", "eq", Param("category", str))) + with pytest.raises(ValueError, match="must be finite"): + await collection.search(vector=[1.0, 0.0], filter=Filter("rating", "ne", float("nan"))) + + +@pytest.mark.parametrize( + "payload", + [ + "x.__class__.__mro__[-1].__subclasses__()", + "x.__init__.__globals__", + "__import__('os').system('echo unsafe')", + "x.clear()", + "x['a']['b']()", + ], +) +async def test_in_memory_treats_filter_payloads_only_as_data(payload: str) -> None: + collection = await _create_collection() + + assert await _result_ids(collection, Filter("text", "eq", payload)) == [] + + +async def test_in_memory_rejects_invalid_vectors() -> None: + collection = await _create_collection() + + with pytest.raises(TypeError, match="field 'query'.*numeric sequence"): + await collection.search(vector=b"\x01\x02") + with pytest.raises(ValueError, match="Query vector field 'vector' expects 2 dimensions; got 1"): + await collection.search(vector=[1.0]) + with pytest.raises(ValueError, match="zero-magnitude"): + await collection.search(vector=[0.0, 0.0]) + + await collection.upsert( + [Document("no-vector", "No vector", "travel", 5, [], None)], + generate_vectors=False, + ) + results = await collection.search(vector=[1.0, 0.0], top=10) + assert [result["record"].id async for result in results] == ["one", "two"] + + await collection.upsert( + [Document("bad-vector", "Bad vector", "travel", 5, [], None, cast(Any, ["bad", 0.0]))], + generate_vectors=False, + ) + with pytest.raises(TypeError, match="Record 'bad-vector'.*field 'vector'"): + await collection.search(vector=[1.0, 0.0], top=10) + + binary_definition = VectorStoreCollectionDefinition( + [ + VectorStoreField("key", name="id"), + VectorStoreField("vector", name="vector", dimensions=8, type_="bytes"), + ], + collection_name="binary", + ) + with pytest.raises(ValueError, match="type must be one of"): + InMemoryCollection(dict, definition=binary_definition) + + +@pytest.mark.parametrize("vector", [[], [1.0], [1.0, 0.0, 0.0]]) +async def test_in_memory_dimension_mismatch_rejects_batch_without_writes(vector: list[float]) -> None: + collection = InMemoryCollection(Document) + await collection.ensure_collection_exists() + + with pytest.raises( + ValueError, match=f"Record at index 1, vector field 'vector' expects 2 dimensions; got {len(vector)}" + ): + await collection.upsert( + [DOCUMENTS[0], Document("invalid", "text", "travel", 5, [], None, vector)], + generate_vectors=False, + ) + + assert await collection.get() == [] + + +@pytest.mark.parametrize("has_records", [False, True]) +@pytest.mark.parametrize("dimensions", [1, 2, 3]) +async def test_in_memory_checks_normalized_array_query_dimensions(has_records: bool, dimensions: int) -> None: + class ArrayLike: + def tolist(self) -> list[float]: + return [1.0] * dimensions + + collection = InMemoryCollection(Document) + await collection.ensure_collection_exists() + if has_records: + await collection.upsert(DOCUMENTS, generate_vectors=False) + + if dimensions != 2: + with pytest.raises(ValueError, match=f"Query vector field 'vector' expects 2 dimensions; got {dimensions}"): + await collection.search(vector=cast(Any, ArrayLike())) + else: + results = await collection.search(vector=cast(Any, ArrayLike())) + assert len([result async for result in results]) == (2 if has_records else 0) + + +def test_in_memory_retains_pairwise_vector_length_check() -> None: + with pytest.raises(ValueError, match="Query and stored vectors must have the same length"): + in_memory_module._calculate_score([1.0, 0.0], [1.0], "cosine_similarity") + + +async def test_in_memory_contains_rejects_mapping_fields() -> None: + definition = VectorStoreCollectionDefinition( + [ + VectorStoreField("key", name="id"), + VectorStoreField("data", name="metadata"), + VectorStoreField("vector", name="vector", dimensions=2), + ], + collection_name="mapping-values", + ) + collection: InMemoryCollection[str, dict[str, Any]] = InMemoryCollection(dict, definition=definition) + await collection.ensure_collection_exists() + await collection.upsert( + [{"id": "one", "metadata": {"wifi": True}, "vector": [1.0, 0.0]}], + generate_vectors=False, + ) + + with pytest.raises(ValueError, match="cannot compare field 'metadata'"): + await collection.search(vector=[1.0, 0.0], filter=Filter("metadata", "contains", "wifi")) + + +def test_in_memory_filter_implementation_does_not_compile_or_evaluate_source() -> None: + tree = ast.parse(inspect.getsource(in_memory_module)) + called_names = { + node.func.id for node in ast.walk(tree) if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + } + + assert called_names.isdisjoint({"compile", "eval", "exec"}) + + +def test_in_memory_and_filter_apis_are_experimental() -> None: + for api in (Filter, FilterGroup, Param, InMemoryCollection, InMemoryStore): + assert getattr(api, "__feature_stage__", None) == "experimental" diff --git a/python/packages/core/tests/core/test_vectors.py b/python/packages/core/tests/core/test_vectors.py index 9bb045e61d7..ac56df4e048 100644 --- a/python/packages/core/tests/core/test_vectors.py +++ b/python/packages/core/tests/core/test_vectors.py @@ -3,10 +3,10 @@ from __future__ import annotations import warnings -from ast import AST, unparse -from collections.abc import AsyncIterable, Mapping, Sequence +from collections.abc import AsyncIterable, Callable, Iterator, Mapping, Sequence from dataclasses import FrozenInstanceError, dataclass, field -from typing import Annotated, Any, ClassVar, cast +from decimal import Decimal +from typing import Annotated, Any, ClassVar, Literal, cast from unittest.mock import patch import msgspec @@ -27,8 +27,11 @@ EmbeddingGenerationOptions, ExperimentalFeature, FieldTypes, + Filter, + FilterGroup, GeneratedEmbeddings, IndexKind, + Param, SearchResponse, SearchResults, SearchType, @@ -42,6 +45,7 @@ ) from agent_framework._feature_stage import ExperimentalWarning from agent_framework._telemetry import FeatureIndex +from agent_framework._vector_filters import filter_values_equal from agent_framework._vectors import _VectorStoreRecordHandler as VectorStoreRecordHandler from agent_framework.exceptions import IntegrationException, IntegrationInvalidResponseException @@ -56,6 +60,7 @@ dimensions=2, index_kind="hnsw", distance_function="cosine_similarity", + provider_annotations={"index": {"ef_construction": 200}}, ), ] @@ -95,15 +100,19 @@ def __init__(self, *, embedding_generator: MockEmbeddingClient | None = None) -> self.created = False self.records: dict[str, dict[str, Any]] = {} self.last_search_type: str | None = None + self.last_search_values: Any | None = None self.last_search_vector: Sequence[float | int] | None = None - self.last_search_filter: Any | list[Any] | None = None + self.last_search_filter: Filter | FilterGroup | None = None + self.last_get_filter: Filter | FilterGroup | None = None self.last_search_top = 0 self.last_search_skip = 0 + self.last_search_score_threshold: float | None = None self.fail_upsert = False self.upsert_error: Exception | None = None self.get_error: Exception | None = None self.delete_error: Exception | None = None self.search_error: Exception | None = None + self.mutate_search_filter = False self.upsert_keys: Sequence[str] | None = None self.raw_search_results: AsyncIterable[Any] | Sequence[Any] | None = None @@ -151,6 +160,7 @@ async def _inner_get( self, *, keys: Sequence[str] | None = None, + filter: Filter | FilterGroup | None = None, top: int = 10, skip: int = 0, order_by: Mapping[str, bool] | None = None, @@ -159,6 +169,7 @@ async def _inner_get( ) -> Sequence[Any] | None: if self.get_error is not None: raise self.get_error + self.last_get_filter = filter if keys is not None: return [self.records[key] for key in keys if key in self.records] return list(self.records.values())[skip : skip + top] @@ -178,7 +189,7 @@ async def _inner_search( self, *, search_type: SearchType, - filter: Any | list[Any] | None = None, + filter: Filter | FilterGroup | None = None, values: Any | None = None, vector: Sequence[float | int] | None = None, top: int = 3, @@ -192,10 +203,14 @@ async def _inner_search( if self.search_error is not None: raise self.search_error self.last_search_type = search_type + self.last_search_values = values self.last_search_vector = vector self.last_search_filter = filter self.last_search_top = top self.last_search_skip = skip + self.last_search_score_threshold = score_threshold + if self.mutate_search_filter and isinstance(filter, Filter) and isinstance(filter.value, list): + filter.value.append("connector mutation") raw_results = self.raw_search_results or [ {"record": record, "score": score} for record, score in zip(self.records.values(), (0.9, 0.4), strict=False) ] @@ -207,9 +222,6 @@ def _get_record_from_result(self, result: Any) -> Any: def _get_score_from_result(self, result: Any) -> float | None: return cast(float | None, result["score"]) - def _lambda_parser(self, node: AST) -> str: - return unparse(node) - StoreModelT = TypeVar("StoreModelT") @@ -289,10 +301,30 @@ def test_vector_field_validates_vector_options() -> None: cast(Any, VectorStoreField)("vector") with pytest.raises(ValueError, match="Vector-only"): cast(Any, VectorStoreField)("data", dimensions=3) - with pytest.raises(ValueError, match="index kind"): - cast(Any, VectorStoreField)("vector", dimensions=3, index_kind="unknown") - with pytest.raises(ValueError, match="distance function"): - cast(Any, VectorStoreField)("vector", dimensions=3, distance_function="unknown") + with pytest.raises(ValueError, match="Only key fields"): + cast(Any, VectorStoreField)("data", is_auto_generated=True) + with pytest.raises(TypeError, match="index_kind must be a string"): + cast(Any, VectorStoreField)("vector", dimensions=3, index_kind=1) + with pytest.raises(TypeError, match="distance_function must be a string"): + cast(Any, VectorStoreField)("vector", dimensions=3, distance_function=object()) + + annotations = {"index": {"ef_construction": 200}} + provider_field = VectorStoreField( + "vector", + dimensions=3, + index_kind="provider.custom_index", + distance_function="provider.custom_distance", + provider_annotations=annotations, + ) + annotations["index"]["ef_construction"] = 100 + assert provider_field.index_kind == "provider.custom_index" + assert provider_field.distance_function == "provider.custom_distance" + assert provider_field.provider_annotations["index"]["ef_construction"] == 200 + provider_field.provider_annotations["index"]["ef_construction"] = 100 + assert provider_field.provider_annotations["index"]["ef_construction"] == 100 + assert hash(provider_field) + with pytest.raises(TypeError, match="keys must be strings"): + VectorStoreField("data", provider_annotations=cast(Any, {1: "value"})) def test_collection_definition_exposes_fields() -> None: @@ -309,8 +341,11 @@ def test_collection_definition_exposes_fields() -> None: assert definition.get_storage_names(include_key_field=False) == ["body", "vector"] assert isinstance(definition.fields, tuple) assert definition.vector_fields[0].dimensions == 2 + assert definition.vector_fields[0].type_ == "float" assert definition.vector_fields[0].index_kind == "hnsw" assert definition.vector_fields[0].distance_function == "cosine_similarity" + assert definition.vector_fields[0].provider_annotations["index"]["ef_construction"] == 200 + assert not definition.key_field.is_auto_generated frozen_field = cast(Any, definition.fields[0]) with pytest.raises(FrozenInstanceError): @@ -320,6 +355,33 @@ def test_collection_definition_exposes_fields() -> None: frozen_definition.fields = () +@pytest.mark.parametrize("value", [0, 1, "false", None]) +@pytest.mark.parametrize("field_type", ["key", "data", "vector"]) +def test_auto_generated_flag_requires_a_boolean(value: Any, field_type: FieldTypes) -> None: + with pytest.raises(TypeError, match="is_auto_generated must be a boolean"): + cast(Any, VectorStoreField)( + field_type, is_auto_generated=value, dimensions=2 if field_type == "vector" else None + ) + + +async def test_bytearray_annotation_uses_normalized_binary_metadata() -> None: + @vectorstoremodel + @dataclass + class BinaryRecord: + id: Annotated[str, VectorStoreField("key")] + vector: Annotated[bytearray | None, VectorStoreField("vector", dimensions=24)] = None + + class BinaryHandler(VectorStoreRecordHandler[str, BinaryRecord]): + supported_vector_types: ClassVar[set[str] | None] = {"bytes"} + + handler = BinaryHandler(BinaryRecord) + assert handler.definition.vector_fields[0].type_ == "bytes" + assert await handler.serialize(BinaryRecord("one", bytearray((1, 2, 3))), generate_vectors=False) == { + "id": "one", + "vector": b"\x01\x02\x03", + } + + @pytest.mark.parametrize( "fields, message", [ @@ -339,6 +401,13 @@ def test_collection_definition_exposes_fields() -> None: ], "must be unique", ), + ( + [ + VectorStoreField("key", name="id", storage_name="record_id"), + VectorStoreField("data", name="record_id", storage_name="body"), + ], + "storage name cannot match another field's model name", + ), ], ) def test_collection_definition_rejects_invalid_fields( @@ -585,6 +654,9 @@ class ArrayRecord: assert isinstance(restored, ArrayRecord) assert restored.vector.values == [0.1, 0.2, 0.3] + with pytest.raises(ValueError, match="vector field 'vector' expects 3 dimensions; got 1"): + await handler.serialize(ArrayRecord("bad", ArrayLike([0.1])), generate_vectors=False) + async def test_custom_encoder_normalizes_array_like_vectors() -> None: class ArrayLike: @@ -646,6 +718,15 @@ async def test_collection_serializes_records_and_generates_vectors() -> None: assert embedding_client.options == {"dimensions": 2} +async def test_collection_empty_upsert_skips_embedding_generation() -> None: + embedding_client = MockEmbeddingClient() + collection = MockCollection(embedding_generator=embedding_client) + + assert await collection.upsert([]) == [] + assert embedding_client.values == [] + assert await MockCollection().upsert([]) == [] + + async def test_upsert_controls_embedding_generation() -> None: embedding_client = MockEmbeddingClient() collection = MockCollection(embedding_generator=embedding_client) @@ -668,6 +749,206 @@ async def test_upsert_controls_embedding_generation() -> None: await MockCollection().upsert([Record("missing-generator", "text", [1.0, 0.0])]) +@pytest.mark.parametrize("vector", [[], [1.0], [1.0, 0.0, 0.0]]) +async def test_upsert_rejects_dimension_mismatches_before_connector_conversion(vector: list[float]) -> None: + collection = MockCollection() + records = [Record("valid", "text", [1.0, 0.0]), Record("invalid", "text", vector)] + + with patch.object( + collection, "_serialize_dicts_to_store_models", wraps=collection._serialize_dicts_to_store_models + ) as convert: + with pytest.raises( + ValueError, match=f"Record at index 1, vector field 'vector' expects 2 dimensions; got {len(vector)}" + ): + await collection.upsert(records, generate_vectors=False) + convert.assert_not_called() + + assert collection.records == {} + + +async def test_upsert_checks_generated_dimensions_instead_of_replaced_input() -> None: + collection = MockCollection(embedding_generator=MockEmbeddingClient()) + record = Record("replaced", "text", [1.0]) + + await collection.upsert([record]) + + assert len(collection.records["replaced"]["vector"]) == 2 + assert record.vector == [1.0] + + +async def test_upsert_rejects_generated_dimension_mismatch_before_writing() -> None: + class WrongDimensionEmbeddingClient(MockEmbeddingClient): + async def get_embeddings( + self, + values: Sequence[Any], + *, + options: EmbeddingGenerationOptions | None = None, + ) -> GeneratedEmbeddings[list[float]]: + return GeneratedEmbeddings([ + Embedding(vector=[1.0, 0.0] if index == 0 else [1.0], dimensions=2) for index, _ in enumerate(values) + ]) + + collection = MockCollection(embedding_generator=WrongDimensionEmbeddingClient()) + + with pytest.raises(ValueError, match="Record at index 1, vector field 'vector' expects 2 dimensions; got 1"): + await collection.upsert([Record("valid", "text", "source"), Record("invalid", "text", "source")]) + + assert collection.records == {} + + +@pytest.mark.parametrize(("field_name", "dimensions"), [("primary", 2), ("secondary", 3)]) +async def test_serialization_validates_all_vector_fields_and_storage_aliases(field_name: str, dimensions: int) -> None: + definition = VectorStoreCollectionDefinition([ + VectorStoreField("key", name="id"), + VectorStoreField("vector", name="primary", storage_name="primary_vector", dimensions=2), + VectorStoreField("vector", name="secondary", storage_name="secondary_vector", dimensions=3), + ]) + handler = VectorStoreRecordHandler(dict, definition=definition) + records = [ + {"id": "one", "primary": [1.0, 0.0], "secondary": [1.0, 0.0, 0.0]}, + {"id": "two", "primary_vector": [1.0, 0.0], "secondary_vector": [1.0, 0.0, 0.0]}, + ] + records[1][f"{field_name}_vector"] = [1.0] + + with pytest.raises( + ValueError, match=f"Record at index 1, vector field '{field_name}' expects {dimensions} dimensions; got 1" + ): + await handler.serialize(records, generate_vectors=False) + + +@pytest.mark.parametrize( + ("payload", "expected"), + [ + (None, None), + ("provider-side source", "provider-side source"), + (b"\x01\x02", b"\x01\x02"), + (bytearray((1, 2)), b"\x01\x02"), + ({"indices": [4], "values": [0.5]}, {"indices": [4], "values": [0.5]}), + ], +) +async def test_serialization_leaves_non_dense_dimensions_to_connector(payload: Any, expected: Any) -> None: + definition = VectorStoreCollectionDefinition([ + VectorStoreField("key", name="id"), + VectorStoreField("vector", name="vector", dimensions=1536), + ]) + handler = VectorStoreRecordHandler(dict, definition=definition) + + assert await handler.serialize({"id": "one", "vector": payload}, generate_vectors=False) == { + "id": "one", + "vector": expected, + } + + +async def test_serialization_selects_vector_fields_for_generation() -> None: + embedding_client = MockEmbeddingClient() + + @dataclass + class MixedVectorRecord: + id: str + local_vector: str | list[float] | None = None + provider_vector: str | list[float] | None = None + + definition = VectorStoreCollectionDefinition([ + VectorStoreField("key", name="id"), + VectorStoreField( + "vector", + name="local_vector", + dimensions=2, + embedding_generator=embedding_client, + ), + VectorStoreField("vector", name="provider_vector", dimensions=2), + ]) + register_vectorstoremodel(MixedVectorRecord, definition=definition) + handler = VectorStoreRecordHandler(MixedVectorRecord) + serialized = await handler.serialize( + MixedVectorRecord("one", "embed locally", "send to provider"), + generate_vectors=["local_vector"], + ) + + assert serialized == { + "id": "one", + "local_vector": [13.0, 0.5], + "provider_vector": "send to provider", + } + assert embedding_client.values == ["embed locally"] + + with pytest.raises(ValueError, match="vector field 'provider_vector' expects 2 dimensions; got 1"): + await handler.serialize( + MixedVectorRecord("bad", "embed locally", [1.0]), + generate_vectors=["local_vector"], + ) + + with pytest.raises(ValueError, match="Unknown vector field"): + await handler.serialize( + MixedVectorRecord("one", "local", "provider"), + generate_vectors=["missing"], + ) + with pytest.raises(ValueError, match="must be unique"): + await handler.serialize( + MixedVectorRecord("one", "local", "provider"), + generate_vectors=["local_vector", "local_vector"], + ) + with pytest.raises(TypeError, match="boolean or a sequence"): + await handler.serialize( + MixedVectorRecord("one", "local", "provider"), + generate_vectors=cast(Any, "local_vector"), + ) + + +async def test_generated_binary_vectors_are_preserved() -> None: + class ByteArrayEmbeddingClient(BaseEmbeddingClient[Any, bytearray, EmbeddingGenerationOptions]): + async def get_embeddings( + self, + values: Sequence[Any], + *, + options: EmbeddingGenerationOptions | None = None, + ) -> GeneratedEmbeddings[bytearray]: + return GeneratedEmbeddings([Embedding(vector=bytearray((1, 2, 3))) for _ in values]) + + @vectorstoremodel + class BinaryRecord(BaseModel): + id: Annotated[str, VectorStoreField("key")] + vector: Annotated[ + str | bytes | None, + VectorStoreField("vector", dimensions=24), + ] = None + + handler = VectorStoreRecordHandler( + BinaryRecord, + embedding_generator=ByteArrayEmbeddingClient(), + ) + serialized = await handler.serialize(BinaryRecord(id="one", vector="source")) + + assert serialized == {"id": "one", "vector": b"\x01\x02\x03"} + assert handler.definition.vector_fields[0].type_ == "bytes" + assert await handler.serialize( + BinaryRecord(id="two", vector=b"\x04\x05\x06"), + generate_vectors=False, + ) == {"id": "two", "vector": b"\x04\x05\x06"} + + @dataclass + class UnsupportedBinaryRecord: + id: Annotated[str, VectorStoreField("key")] + vector: Annotated[str | bytes | None, VectorStoreField("vector", dimensions=24)] = None + + with pytest.raises(ValueError, match="default msgspec decoder.*custom decoder"): + vectorstoremodel(UnsupportedBinaryRecord) + + @vectorstoremodel + class BytesOnlyRecord(BaseModel): + id: Annotated[str, VectorStoreField("key")] + vector: Annotated[bytes | None, VectorStoreField("vector", dimensions=24)] = None + + pydantic_handler = VectorStoreRecordHandler(BytesOnlyRecord) + supplied = BytesOnlyRecord(id="three", vector=b"\x07\x08\x09") + serialized_supplied = await pydantic_handler.serialize(supplied, generate_vectors=False) + restored = pydantic_handler.deserialize(serialized_supplied) + + assert serialized_supplied == {"id": "three", "vector": b"\x07\x08\x09"} + assert isinstance(restored, BytesOnlyRecord) + assert restored.vector == b"\x07\x08\x09" + + async def test_collection_crud_preserves_single_and_batch_shapes() -> None: collection = MockCollection(embedding_generator=MockEmbeddingClient()) await collection.ensure_collection_exists() @@ -706,6 +987,18 @@ async def test_collection_get_without_keys_lists_records() -> None: assert await MockCollection().get() == [] +async def test_collection_get_accepts_filter_as_alternate_retrieval_mode() -> None: + collection = MockCollection() + filter_ = Filter("text", "eq", "hello") + + await collection.get(filter=filter_) + + assert collection.last_get_filter == filter_ + assert collection.last_get_filter is not filter_ + with pytest.raises(ValueError, match="alternate retrieval modes"): + await collection.get(["one"], filter=filter_) + + async def test_collection_crud_rejects_singular_ordinary_inputs() -> None: collection = MockCollection() @@ -717,7 +1010,7 @@ async def test_collection_crud_rejects_singular_ordinary_inputs() -> None: await collection.delete("one") -async def test_vector_search_generates_query_vector_and_filters_threshold() -> None: +async def test_vector_search_generates_query_vector_and_forwards_threshold() -> None: embedding_client = MockEmbeddingClient() collection = MockCollection(embedding_generator=embedding_client) await collection.upsert([ @@ -734,9 +1027,11 @@ async def test_vector_search_generates_query_vector_and_filters_threshold() -> N assert results.metadata == {"mock_count": 2} assert embedding_client.values == ["find this"] assert collection.last_search_vector == [9.0, 0.5] + assert collection.last_search_score_threshold == 0.5 assert responses[0]["record"].id == "one" assert responses[0]["score"] == 0.9 - assert len(responses) == 1 + assert [response["record"].id for response in responses] == ["one", "two"] + assert responses[1]["score"] == 0.4 async def test_keyword_hybrid_search_uses_single_search_method() -> None: @@ -747,11 +1042,110 @@ async def test_keyword_hybrid_search_uses_single_search_method() -> None: assert collection.last_search_type == "keyword_hybrid" +@pytest.mark.parametrize("values", ["vectorize this on the provider", {"indices": [4], "values": [0.5]}, [1.0]]) +async def test_search_passes_values_to_provider_when_no_generator_is_configured(values: Any) -> None: + collection = MockCollection() + + await collection.search(values) + + assert collection.last_search_values is values + assert collection.last_search_vector is None + + +@pytest.mark.parametrize("vector", [[], [1.0], (1.0,), range(1), [1.0, 0.0, 0.0]]) +@pytest.mark.parametrize("search_type", ["vector", "keyword_hybrid"]) +async def test_search_rejects_dense_dimension_mismatches_before_dispatch( + vector: Sequence[float], search_type: SearchType +) -> None: + collection = MockCollection() + + with pytest.raises(ValueError, match=f"Query vector field 'vector' expects 2 dimensions; got {len(vector)}"): + await collection.search("query", vector=vector, search_type=search_type) + + assert collection.last_search_type is None + + +@pytest.mark.parametrize( + ("selected_field", "logical_name", "dimensions"), + [(None, "vector", 2), ("secondary", "secondary", 3), ("secondary_vector", "secondary", 3)], +) +async def test_search_checks_dimensions_of_selected_vector_field( + selected_field: str | None, logical_name: str, dimensions: int +) -> None: + collection = MockCollection() + collection.definition = VectorStoreCollectionDefinition([ + VectorStoreField("key", name="id"), + VectorStoreField("vector", name="vector", dimensions=2), + VectorStoreField("vector", name="secondary", storage_name="secondary_vector", dimensions=3), + ]) + vector = [1.0] * dimensions + + await collection.search(vector=vector, vector_property_name=selected_field) + assert collection.last_search_vector is vector + + with pytest.raises( + ValueError, match=f"Query vector field '{logical_name}' expects {dimensions} dimensions; got {dimensions + 1}" + ): + await collection.search(vector=[1.0] * (dimensions + 1), vector_property_name=selected_field) + assert collection.last_search_vector is vector + + +async def test_search_rejects_generated_dimension_mismatch() -> None: + class WrongDimensionEmbeddingClient(MockEmbeddingClient): + async def get_embeddings( + self, + values: Sequence[Any], + *, + options: EmbeddingGenerationOptions | None = None, + ) -> GeneratedEmbeddings[list[float]]: + return GeneratedEmbeddings([Embedding(vector=[1.0], dimensions=2)]) + + collection = MockCollection(embedding_generator=WrongDimensionEmbeddingClient()) + + with pytest.raises(ValueError, match="Query vector field 'vector' expects 2 dimensions; got 1"): + await collection.search("source") + + assert collection.last_search_type is None + + +async def test_search_dimension_check_does_not_iterate_or_copy_vectors() -> None: + class LengthOnlyVector(list[float]): + def __iter__(self) -> Iterator[float]: + raise AssertionError("Dimension checks must not iterate vector elements.") + + vector = LengthOnlyVector([1.0, 0.0]) + collection = MockCollection() + + await collection.search(vector=vector) + + assert collection.last_search_vector is vector + + +@pytest.mark.parametrize("vector", [(1.0, 0.0), range(2)]) +async def test_search_accepts_matching_dense_sequence_dimensions(vector: Sequence[float | int]) -> None: + collection = MockCollection() + + await collection.search(vector=vector) + + assert collection.last_search_vector is vector + + +@pytest.mark.parametrize("vector", [b"\x01", bytearray((1,))]) +async def test_search_leaves_binary_dimensions_to_connector(vector: bytes | bytearray) -> None: + collection = MockCollection() + + await collection.search(vector=vector) + + assert collection.last_search_vector is vector + + async def test_vector_search_validates_inputs_and_supported_type() -> None: collection = MockCollection() with pytest.raises(ValueError, match="requires values"): await cast(Any, collection.search)() + with pytest.raises(ValueError, match="Vector field 'missing' was not found"): + await collection.search(vector=[1.0, 0.0], vector_property_name="missing") class VectorOnlyCollection(MockCollection): supported_search_types: ClassVar[set[SearchType]] = {"vector"} @@ -760,17 +1154,107 @@ class VectorOnlyCollection(MockCollection): await VectorOnlyCollection().search("words", search_type="keyword_hybrid") -async def test_vector_search_requires_explicit_distance_for_score_threshold() -> None: +@pytest.mark.parametrize( + "distance_function", [None, "DEFAULT", "provider.custom_distance", "cosine_similarity", "cosine_distance"] +) +@pytest.mark.parametrize("precomputed", [False, True]) +@pytest.mark.parametrize("threshold", [0.0, 0.5]) +async def test_core_preserves_connector_scores_without_interpreting_thresholds( + distance_function: DistanceFunction | None, precomputed: bool, threshold: float +) -> None: collection = MockCollection() + fields = [ + VectorStoreField("key", name="id", storage_name="record_id"), + VectorStoreField("data", name="text", storage_name="body"), + ] + if distance_function is not None: + fields.append( + VectorStoreField("vector", name="vector", type_="float", dimensions=2, distance_function=distance_function) + ) + collection.definition = VectorStoreCollectionDefinition( + fields, + collection_name="records", + ) + collection.raw_search_results = [ + {"record": {"record_id": "low", "body": "low"}, "score": -0.2}, + {"record": {"record_id": "equal", "body": "equal"}, "score": threshold}, + {"record": {"record_id": "high", "body": "high"}, "score": 0.9}, + {"record": {"record_id": "scoreless", "body": "scoreless"}, "score": None}, + ] + filter_ = Filter("text", "eq", "provider interprets this") + + results = await collection.search( + "query", + vector=[1.0, 0.0] if precomputed else None, + filter=filter_, + score_threshold=threshold, + top=4, + skip=2, + ) + responses = [response async for response in results] + + assert collection.last_search_score_threshold == threshold + assert collection.last_search_filter == filter_ + assert collection.last_search_top == 4 + assert collection.last_search_skip == 2 + assert [response["record"].id for response in responses] == ["low", "equal", "high", "scoreless"] + assert [response["score"] for response in responses] == [-0.2, threshold, 0.9, None] + + +async def test_connector_can_enforce_provider_threshold_before_core_deserialization() -> None: + class ProviderThresholdCollection(MockCollection): + async def _inner_search( + self, + *, + search_type: SearchType, + filter: Filter | FilterGroup | None = None, + values: Any | None = None, + vector: Sequence[float | int] | bytes | bytearray | None = None, + top: int = 3, + skip: int = 0, + include_vectors: bool = False, + vector_property_name: str | None = None, + additional_property_name: str | None = None, + score_threshold: float | None = None, + operation_options: Mapping[str, Any] | None = None, + ) -> SearchResults[Any]: + self.last_search_score_threshold = score_threshold + assert self.raw_search_results is not None + results = SearchResults(self.raw_search_results) + return SearchResults([ + result async for result in results if score_threshold is None or result["score"] <= score_threshold + ]) + + collection = ProviderThresholdCollection() collection.definition = VectorStoreCollectionDefinition( [ - VectorStoreField("key", name="id", type_="str"), - VectorStoreField("vector", name="vector", type_="float", dimensions=2), + VectorStoreField("key", name="id", storage_name="record_id"), + VectorStoreField("data", name="text", storage_name="body"), + VectorStoreField( + "vector", + name="vector", + dimensions=2, + distance_function="provider.custom_distance", + ), ], collection_name="records", ) + collection.raw_search_results = [ + {"record": {"record_id": "rejected", "body": "far"}, "score": 0.9}, + {"record": {"record_id": "accepted", "body": "near"}, "score": 0.2}, + ] + + results = await collection.search(vector=[1.0, 0.0], score_threshold=0.5) - with pytest.raises(ValueError, match="explicit distance"): + assert collection.last_search_score_threshold == 0.5 + assert [(result["record"].id, result["score"]) async for result in results] == [("accepted", 0.2)] + + +async def test_connector_can_reject_unsupported_threshold_execution() -> None: + collection = MockCollection() + collection.search_error = NotImplementedError("Connector does not support score thresholds.") + + with pytest.raises(NotImplementedError, match="Connector does not support score thresholds"): await collection.search(vector=[1.0, 0.0], score_threshold=0.5) @@ -790,30 +1274,25 @@ async def get_embeddings( await collection.search("query") -def test_vector_search_builds_connector_filter() -> None: - collection = MockCollection() - - assert collection._build_filter("lambda record: record.category == 'travel'") == "record.category == 'travel'" - assert collection._build_filter([ - "lambda record: record.category == 'travel'", - "lambda record: record.id != 'ignored'", - ]) == ["record.category == 'travel'", "record.id != 'ignored'"] - - -async def test_vector_search_passes_translated_filter_to_connector() -> None: +async def test_vector_search_passes_filter_to_connector() -> None: collection = MockCollection() + search_filter = FilterGroup("and", (Filter("text", "eq", "travel"), Filter("id", "ne", "ignored"))) await collection.search( "query", - filter="lambda record: record.category == 'travel'", + filter=search_filter, ) - assert collection.last_search_filter == "record.category == 'travel'" + assert collection.last_search_filter == search_filter + assert collection.last_search_filter is not search_filter -def test_vector_search_rejects_filter_without_lambda() -> None: - with pytest.raises(ValueError, match="No lambda"): - MockCollection()._build_filter("record.category == 'travel'") +async def test_vector_search_rejects_invalid_or_unresolved_filters() -> None: + collection = MockCollection() + with pytest.raises(ValueError, match="not part of the vector store definition"): + await collection.search("query", filter=Filter("missing", "eq", "value")) + with pytest.raises(ValueError, match="must be resolved"): + await collection.search("query", filter=Filter("text", "eq", Param("text", str))) async def test_create_search_tool_returns_mapped_results() -> None: @@ -838,62 +1317,54 @@ async def test_create_search_tool_returns_mapped_results() -> None: async def test_create_search_tool_supports_declared_filter_parameters() -> None: collection = MockCollection() collection.records["one"] = {"record_id": "one", "body": "first", "vector": [1.0, 0.0]} + category = Param( + "category", + Literal["travel", "work"], + required=True, + description="The category to match.", + ) tool = create_vector_search_tool( collection, - parameters={ - "type": "object", - "properties": { - "query": {"type": "string", "description": "The search query."}, - "category": {"type": "string", "description": "The category to match."}, - "top": { - "type": "integer", - "description": "The maximum number of results.", - "maximum": 5, - }, - "skip": { - "type": "integer", - "description": "The number of results to skip.", - "maximum": 10, - }, - }, - "required": ["query", "category"], - "additionalProperties": False, - }, + filter=Filter("text", "eq", category), + top=Param("top", int, default=1, minimum=1, maximum=5), + skip=Param("skip", int, default=0, minimum=0, maximum=10), ) await tool(query="first", category="travel", top=1, skip=2) assert set(tool.parameters()["properties"]) == {"query", "category", "top", "skip"} - assert collection.last_search_filter == "record.category == 'travel'" + assert tool.parameters()["additionalProperties"] is False + assert tool.parameters()["properties"]["category"]["enum"] == ["travel", "work"] + assert tool.parameters()["properties"]["top"]["minimum"] == 1 + assert tool.parameters()["properties"]["top"]["maximum"] == 5 + assert collection.last_search_filter == Filter("text", "eq", "travel") assert collection.last_search_top == 1 assert collection.last_search_skip == 2 -def test_create_search_tool_validates_custom_schema() -> None: +def test_create_search_tool_validates_params() -> None: collection = MockCollection() - with pytest.raises(ValueError, match="required string"): - create_vector_search_tool(collection, parameters={"type": "object", "properties": {}}) - with pytest.raises(ValueError, match="required string"): + with pytest.raises(ValueError, match="minimum"): create_vector_search_tool( collection, - parameters={ - "type": "object", - "properties": {"query": {"type": "integer"}}, - "required": ["query"], - }, + top=Param("top", int, default=1, maximum=5), ) - with pytest.raises(ValueError, match="declare an integer maximum"): + with pytest.raises(ValueError, match="finite integer maximum"): create_vector_search_tool( collection, - parameters={ - "type": "object", - "properties": { - "query": {"type": "string"}, - "top": {"type": "integer"}, - }, - "required": ["query"], - }, + top=Param("top", int, default=1, minimum=1), + ) + with pytest.raises(ValueError, match="conflicting declarations"): + create_vector_search_tool( + collection, + filter=FilterGroup( + "and", + ( + Filter("id", "eq", Param("record_id", str)), + Filter("text", "eq", Param("record_id", int)), + ), + ), ) @@ -904,25 +1375,22 @@ async def test_create_search_tool_enforces_paging_limits_and_result_cap() -> Non ] tool = create_vector_search_tool( collection, - top=2, - parameters={ - "type": "object", - "properties": { - "query": {"type": "string"}, - "top": {"type": "integer", "maximum": 2}, - "skip": {"type": "integer", "maximum": 4}, - }, - "required": ["query"], - }, + top=Param("top", int, default=2, minimum=1, maximum=2), + skip=Param("skip", int, default=0, minimum=0, maximum=4), ) results = await tool(query="records", top=2, skip=4) assert len(results) == 2 - with pytest.raises(ValueError, match="top must not exceed"): + with pytest.raises(ValueError, match="must be at most 2"): await tool(query="records", top=3) - with pytest.raises(ValueError, match="skip must not exceed"): + with pytest.raises(ValueError, match="must be at most 4"): await tool(query="records", skip=5) + with pytest.raises(TypeError, match="does not match"): + await create_vector_search_tool( + collection, + filter=Filter("text", "eq", Param("category", Literal["travel", "work"], required=True)), + )(query="records", category="other") async def test_create_search_tool_supports_multimodal_results() -> None: @@ -1301,43 +1769,684 @@ async def test_scoreless_results_remain_when_threshold_cannot_be_applied() -> No assert responses[0]["score"] is None -def test_filter_parser_and_default_mapper_edge_paths() -> None: - collection = MockCollection() - assert collection._build_filter(lambda record: record.id == "one") == "record.id == 'one'" - with pytest.raises(ValueError, match="Unable to parse"): - collection._build_filter("lambda record:") - +@pytest.mark.parametrize( + ("left", "right", "expected"), + [ + (True, 1, False), + (False, 0, False), + (True, True, True), + (False, False, True), + (1, 1.0, True), + ([1], [True], False), + ([0], [False], False), + ((1,), (True,), False), + ([[1]], [[True]], False), + ([(1, [0])], [(True, [False])], False), + ([True, [False]], [True, [False]], True), + ([1, [0]], [1.0, [0.0]], True), + ((1, (0,)), (1.0, (0.0,)), True), + ([1], (1,), False), + ([], (), False), + ([1, 2], [2, 1], False), + ([1], [1, 2], False), + ([], [], True), + ({"value": [1]}, {"value": [True]}, False), + ([{"value": (0,)}], [{"value": (False,)}], False), + ({"value": [1]}, {"value": [1.0]}, True), + ({"one": True, "two": False}, {"two": False, "one": True}, True), + ({"one": True}, {"two": True}, False), + ({"value": True}, {}, False), + ("1", 1, False), + ("text", "text", True), + (b"\x01", b"\x01", True), + (None, None, True), + ], +) +def test_filter_values_equal_preserves_nested_types(left: Any, right: Any, expected: bool) -> None: + assert filter_values_equal(left, right) is expected + assert filter_values_equal(right, left) is expected + + +def test_filter_model_accepts_namespaced_provider_operators() -> None: + assert Filter("text", "azure_ai_search.full_text", "query").operator == "azure_ai_search.full_text" + with pytest.raises(ValueError, match="namespaced"): + Filter("text", "full_text", "query") + + +def test_filter_model_rejects_invalid_shapes() -> None: + with pytest.raises(ValueError, match="requires a value"): + Filter("text", "eq") + with pytest.raises(TypeError, match="sequence value"): + Filter("text", "in", "value") + with pytest.raises(ValueError, match="two boundary"): + Filter("text", "between", (1,)) + with pytest.raises(ValueError, match="exactly one"): + FilterGroup("not", (Filter("text", "eq", "one"), Filter("text", "eq", "two"))) + with pytest.raises(ValueError, match="Invalid filter field name"): + Filter("__class__", "eq", "unsafe") + + cyclic: list[Any] = [] + cyclic.append(cyclic) + with pytest.raises(ValueError, match="cycles"): + Filter("text", "in", cyclic) + with pytest.raises(ValueError, match="entire Filter value"): + Filter("text", "in", ("fixed", Param("dynamic", str))) + + +def test_param_generates_native_schema_and_validates_constraints() -> None: + param = Param( + "category", + Literal["travel", "work"], + description="The category.", + required=True, + ) + tool = create_vector_search_tool(MockCollection(), filter=Filter("text", "eq", param)) -async def test_search_tool_filter_mapper_edge_paths() -> None: - collection = MockCollection() - parameters = { + assert tool.parameters() == { "type": "object", "properties": { - "query": {"type": "string"}, - "category": {"type": "string"}, + "query": {"type": "string", "description": "The query to search for."}, + "category": { + "enum": ["travel", "work"], + "type": "string", + "description": "The category.", + }, }, "required": ["query", "category"], + "additionalProperties": False, } + + with pytest.raises(TypeError, match="cannot be represented"): + Param("unsupported", object) + with pytest.raises(ValueError, match="minimum cannot exceed"): + Param("invalid_range", float, minimum=2, maximum=1) + + optional_code = Param("code", str | None, max_length=8) + schema = create_vector_search_tool(MockCollection(), filter=Filter("text", "eq", optional_code)).parameters() + assert {"type": "string", "maxLength": 8} in schema["properties"]["code"]["anyOf"] + with pytest.raises(TypeError, match="homogeneous"): + Param("pair", tuple[int, str]) + with pytest.raises(TypeError, match="JSON-compatible"): + Param("binary_literal", Literal[b"value"]) + non_finite_literal = cast(Any, Literal)[float("inf")] + with pytest.raises(ValueError, match="Literal numbers must be finite"): + Param("non_finite_literal", non_finite_literal) + with pytest.raises(TypeError, match="mapping keys must use the str type"): + Param("integer_keys", dict[int, str]) + with pytest.raises(ValueError, match="numeric constraints require numeric"): + Param("category_with_minimum", str, minimum=1) + with pytest.raises(ValueError, match="numeric constraints require numeric"): + Param("mixed_with_minimum", int | str, minimum=1) + + tuple_param = Param("numbers", tuple[int, ...]) + tuple_schema = create_vector_search_tool( + MockCollection(), + filter=Filter("text", "provider.numbers", tuple_param), + ).parameters() + assert tuple_schema["properties"]["numbers"] == {"type": "array", "items": {"type": "integer"}} + + nullable_number = Param("score", float | None, minimum=0, maximum=1) + nullable_number_schema = create_vector_search_tool( + MockCollection(), + filter=Filter("text", "provider.score", nullable_number), + ).parameters() + assert nullable_number_schema["properties"]["score"] == { + "anyOf": [{"type": "number"}, {"type": "null"}], + "minimum": 0, + "maximum": 1, + } + assert Param("level", Literal[1, None], minimum=1).minimum == 1 + + source_default = ["travel"] + frozen_default = Param("categories", list[str], default=source_default) + source_default.append("work") + returned_default = frozen_default.default + returned_default.append("personal") + assert frozen_default.default == ["travel"] + + +def test_filter_and_param_constructor_boundaries() -> None: + with pytest.raises(ValueError, match="cannot be empty"): + Param("", str) + with pytest.raises(ValueError, match="reserved"): + Param("query", str) + with pytest.raises(ValueError, match="required parameter cannot declare a default"): + Param("required", str, required=True, default="value") + with pytest.raises(ValueError, match="finite number"): + Param("minimum", float, minimum=float("inf")) + with pytest.raises(ValueError, match="non-negative integer"): + Param("length", str, min_length=-1) + with pytest.raises(ValueError, match="cannot exceed max_length"): + Param("length", str, min_length=2, max_length=1) + with pytest.raises(TypeError, match="field_name must be a string"): + Filter(cast(Any, 1), "eq", "value") + with pytest.raises(TypeError, match="operator must be a string"): + Filter("text", cast(Any, 1), "value") + with pytest.raises(ValueError, match="does not accept a value"): + Filter("text", "is_null", "value") + with pytest.raises(TypeError, match="operator must be a string"): + FilterGroup(cast(Any, 1), (Filter("text", "eq", "value"),)) + with pytest.raises(ValueError, match="Unknown filter group"): + FilterGroup(cast(Any, "xor"), (Filter("text", "eq", "value"),)) + with pytest.raises(TypeError, match="must be a sequence"): + FilterGroup("and", cast(Any, "not-a-sequence")) + with pytest.raises(ValueError, match="at least one"): + FilterGroup("and", ()) + with pytest.raises(TypeError, match="Filter or FilterGroup"): + FilterGroup("and", cast(Any, (object(),))) + + +async def test_param_native_schema_and_runtime_validation_for_container_types() -> None: + items = Param("items", list[str], min_length=1, max_length=3) + flags = Param("flags", dict[str, int], max_length=2) + enabled = Param("enabled", bool, required=True) + collection = MockCollection() tool = create_vector_search_tool( collection, - parameters=parameters, - filter=["lambda record: record.id != 'ignored'"], + filter=FilterGroup( + "and", + ( + Filter("text", "eq", items), + Filter("text", "eq", flags), + Filter("text", "eq", enabled), + ), + ), ) - await tool(query="query", category="travel") - assert collection.last_search_filter == ["record.id != 'ignored'", "record.category == 'travel'"] - invalid_tool = create_vector_search_tool( + schema = tool.parameters() + assert schema["properties"]["items"] == { + "type": "array", + "items": {"type": "string"}, + "minItems": 1, + "maxItems": 3, + } + assert schema["properties"]["flags"] == { + "type": "object", + "additionalProperties": {"type": "integer"}, + "maxProperties": 2, + } + assert schema["required"] == ["query", "enabled"] + + await tool(query="query", items=["a"], flags={"one": 1}, enabled=True) + with pytest.raises(TypeError, match="does not match"): + await tool(query="query", items=[1], enabled=True) + with pytest.raises(ValueError, match="longer than 3"): + await tool(query="query", items=["a", "b", "c", "d"], enabled=True) + with pytest.raises(TypeError, match="Missing required"): + await tool(query="query") + + +async def test_filter_resource_validation_boundaries() -> None: + collection = MockCollection() + invalid_mapping = Filter("text", "provider.native", cast(Any, {1: "value"})) + with pytest.raises(TypeError, match="mapping keys must be strings"): + await collection.search("query", filter=invalid_mapping) + + mutable_value: list[Any] = ["fixed"] + mutated_filter = Filter("text", "in", mutable_value) + mutable_value.append(Param("dynamic", str)) + with pytest.raises(ValueError, match="entire Filter value"): + await collection.search("query", filter=mutated_filter) + + too_many = FilterGroup("and", (Filter("text", "eq", "value"),)) + too_many.filters = tuple(Filter("text", "eq", str(index)) for index in range(65)) + with pytest.raises(ValueError, match="more than 64 nodes"): + await collection.search("query", filter=too_many) + + cyclic_group = FilterGroup("and", (Filter("text", "eq", "value"),)) + cyclic_group.filters = (cyclic_group,) + with pytest.raises(ValueError, match="cannot contain cycles"): + await collection.search("query", filter=cyclic_group) + + cyclic_expression_value = Filter("text", "provider.nested", "value") + cyclic_expression_value.value = cyclic_expression_value + with pytest.raises(ValueError, match="cannot contain cycles"): + await collection.search("query", filter=cyclic_expression_value) + + nested: Filter | FilterGroup = Filter("relative_name", "eq", "value") + assert Filter("text", "provider.nested", nested).value is nested + + +@pytest.mark.parametrize("operation", ["get", "search", "tool"]) +async def test_filter_limits_are_checked_before_snapshot_copy(operation: str) -> None: + collection = MockCollection() + expression: Filter | FilterGroup = Filter("text", "eq", "value") + for _ in range(1100): + expression = FilterGroup("and", (expression,)) + + with patch("agent_framework._vector_filters.deepcopy") as copy: + with pytest.raises(ValueError, match="depth of 8"): + if operation == "get": + await collection.get(filter=expression) + elif operation == "search": + await collection.search("query", filter=expression) + else: + create_vector_search_tool(collection, filter=expression) + copy.assert_not_called() + + +def test_filter_group_constructor_bounds_child_copying() -> None: + child = Filter("text", "eq", "value") + + class LargeFilterSequence(Sequence[Filter]): + def __init__(self) -> None: + self.visited = 0 + + def __len__(self) -> int: + return 1_000_000 + + def __getitem__(self, index: Any) -> Any: + self.visited += 1 + assert self.visited <= 64 + return child + + filters = LargeFilterSequence() + with pytest.raises(ValueError, match="more than 64 nodes"): + FilterGroup("and", filters) + assert filters.visited == 64 + assert len(FilterGroup("and", [child] * 63).filters) == 63 + + +async def test_filter_value_traversal_stops_at_node_budget_before_copy() -> None: + class LargeSequence(Sequence[int]): + def __init__(self) -> None: + self.visited = 0 + + def __len__(self) -> int: + return 1_000_000 + + def __getitem__(self, index: Any) -> Any: + self.visited += 1 + assert self.visited <= 64 + return index + + def __deepcopy__(self, memo: dict[int, Any]) -> Any: + raise AssertionError("Oversized values must not be copied.") + + values = LargeSequence() + expression = Filter("text", "provider.values", []) + expression.value = values + with pytest.raises(ValueError, match="more than 64 nodes"): + await MockCollection().search("query", filter=expression) + assert values.visited == 64 + + +async def test_filter_collection_values_keep_existing_node_budget_and_snapshot_semantics() -> None: + values = set(range(63)) + expression = Filter("text", "provider.native", values) + collection = MockCollection() + tool = create_vector_search_tool(collection, filter=expression) + values.add(63) + + await tool(query="query") + assert collection.last_search_filter == Filter("text", "provider.native", set(range(63))) + with pytest.raises(ValueError, match="more than 64 nodes"): + await collection.search("query", filter=expression) + + +@pytest.mark.parametrize( + "wrap", + [ + lambda param: {param}, + lambda param: frozenset((param,)), + lambda param: {"key": param}.values(), + lambda param: {param: "value"}, + lambda param: {(param,): "value"}, + lambda param: {"nested": {param}}, + ], +) +def test_filters_reject_params_in_collection_values_and_mapping_keys(wrap: Callable[[Param], Any]) -> None: + with pytest.raises(ValueError, match="entire Filter value"): + Filter("text", "provider.native", wrap(Param("hidden", str))) + + +def test_filter_constructor_bounds_nested_value_inspection() -> None: + value: Any = "value" + for _ in range(1100): + value = [value] + with pytest.raises(ValueError, match="depth of 8"): + Filter("text", "provider.nested", value) + + +def test_param_default_is_bounded_before_copying() -> None: + with patch("agent_framework._vector_filters.deepcopy") as copy: + with pytest.raises(ValueError, match="more than 256 nodes"): + Param("values", list[int], default=list(range(300))) + copy.assert_not_called() + + +@pytest.mark.parametrize("operator", ["starts_with", "ends_with", "contains_text"]) +@pytest.mark.parametrize("value", [1, False, []]) +def test_string_filter_operators_require_string_operands(operator: str, value: Any) -> None: + with pytest.raises(TypeError, match="must be a string"): + Filter("text", operator, value) + + +@pytest.mark.parametrize("case", ["nested_param", "string_operand", "combined_budget"]) +async def test_definitionless_search_still_validates_portable_filter_structure(case: str) -> None: + class SearchOnly: + async def search(self, values: Any, **kwargs: Any) -> SearchResults[SearchResponse[Record]]: + raise AssertionError("Invalid filter reached the connector.") + + search = cast(SupportsVectorSearch[Record], SearchOnly()) + if case == "nested_param": + expression = Filter("text", "provider.native", set()) + expression.value.add(Param("hidden", str)) + with pytest.raises(ValueError, match="entire Filter value"): + create_vector_search_tool(search, filter=expression) + elif case == "string_operand": + tool = create_vector_search_tool(search, filter=Filter("text", "contains_text", Param("text", int))) + with pytest.raises(TypeError, match="must be a string"): + await tool(query="query", text=1) + else: + tool = create_vector_search_tool( + search, + filter=FilterGroup( + "and", + ( + Filter("first", "in", Param("first", list[str])), + Filter("second", "in", Param("second", list[str])), + ), + ), + ) + with pytest.raises(ValueError, match="more than 64 nodes"): + await tool(query="query", first=["one"] * 40, second=["two"] * 40) + + +async def test_search_tool_param_edge_paths() -> None: + collection = MockCollection() + category = Param("category", str) + tool = create_vector_search_tool( collection, - parameters={ - "type": "object", - "properties": {"query": {"type": "string"}, "bad-name": {"type": "string"}}, - "required": ["query"], - }, + filter=FilterGroup( + "and", + ( + Filter("id", "ne", "ignored"), + Filter("text", "eq", category), + ), + ), ) - with pytest.raises(ValueError, match="cannot be mapped"): - await invalid_tool(query="query", **{"bad-name": "value"}) + await tool(query="query", category="travel") + assert collection.last_search_filter == FilterGroup( + "and", + ( + Filter("id", "ne", "ignored"), + Filter("text", "eq", "travel"), + ), + ) + + await tool(query="query") + assert collection.last_search_filter == FilterGroup("and", (Filter("id", "ne", "ignored"),)) + + with pytest.raises(ValueError, match="Invalid parameter name"): + Param("bad-name", str) with pytest.raises(TypeError, match="'query'.*string"): await cast(Any, create_vector_search_tool(collection))(query=1) + with pytest.raises(TypeError, match="Unexpected argument"): + await tool(query="query", undeclared="value") + with pytest.raises(ValueError, match="must be finite"): + await create_vector_search_tool( + collection, + filter=Filter("text", "eq", Param("score", float)), + )(query="query", score=float("nan")) + with pytest.raises(TypeError, match="does not match"): + await create_vector_search_tool( + collection, + filter=Filter("text", "eq", Param("level", Literal[1, 2], required=True)), + )(query="query", level=True) + + nullable_tool = create_vector_search_tool( + collection, + filter=Filter("text", "provider.nullable", Param("nullable", str | None)), + ) + await nullable_tool(query="query", nullable=None) + assert collection.last_search_filter == Filter("text", "provider.nullable", None) + + nested_values_tool = create_vector_search_tool( + collection, + filter=Filter("text", "provider.values", Param("values", list[list[int]])), + ) + with pytest.raises(ValueError, match="more than 256 nodes"): + await nested_values_tool(query="query", values=[[index for index in range(20)] for _ in range(20)]) + + with pytest.raises(ValueError, match="must be finite"): + await collection.search("query", filter=Filter("text", "provider.number", Decimal("NaN"))) + + +@pytest.mark.parametrize("value_type", [str, int, list[str | None]]) +def test_param_omit_if_none_requires_nullable_type(value_type: Any) -> None: + with pytest.raises(ValueError, match="value type that accepts None"): + Param("value", value_type, default=None, omit_if_none=True) + + +def test_param_omit_if_none_requires_explicit_null_default() -> None: + with pytest.raises(ValueError, match="explicit default=None"): + Param("text", str | None, omit_if_none=True) + with pytest.raises(ValueError, match="explicit default=None"): + Param("text", str | None, default="hotel", omit_if_none=True) + with pytest.raises(ValueError, match="required parameter cannot declare a default"): + Param("text", str | None, required=True, default=None, omit_if_none=True) + with pytest.raises(TypeError, match="omit_if_none must be a boolean"): + Param("text", str | None, default=None, omit_if_none=cast(Any, "true")) + + +async def test_param_omit_if_none_exposes_nullable_schema_and_validates_values() -> None: + param = Param("text", str | None, default=None, omit_if_none=True, max_length=8) + tool = create_vector_search_tool(MockCollection(), filter=Filter("text", "contains_text", param)) + + assert param.omit_if_none + assert param.has_default + assert param.default is None + assert tool.parameters()["properties"]["text"] == { + "anyOf": [{"type": "string", "maxLength": 8}, {"type": "null"}], + "default": None, + } + assert tool.parameters()["required"] == ["query"] + with pytest.raises(TypeError, match="does not match"): + await tool(query="query", text=123) + with pytest.raises(ValueError, match="longer than 8"): + await tool(query="query", text="too long a value") + + +@pytest.mark.parametrize( + ("operator", "value_type", "supplied"), + [ + ("contains_text", str | None, "hotel"), + ("contains_text", str | None, ""), + ("contains_text", str | None, "*"), + ("eq", Literal["hotel", None], "hotel"), + ("gte", float | None, 0), + ("in", list[str] | None, []), + ("provider.enabled", bool | None, False), + ], +) +async def test_search_tool_omits_only_null_param_values(operator: str, value_type: Any, supplied: Any) -> None: + collection = MockCollection() + param = Param("value", value_type, default=None, omit_if_none=True) + search_filter = Filter("text", operator, param) + tool = create_vector_search_tool(collection, filter=search_filter) + + await tool(query="query") + assert collection.last_search_filter is None + await tool(query="query", value=None) + assert collection.last_search_filter is None + await tool(query="query", value=supplied) + assert collection.last_search_filter == Filter("text", operator, supplied) + await tool(query="query", value=None) + assert collection.last_search_filter is None + assert search_filter.value is param + + +@pytest.mark.parametrize("operator", ["and", "or"]) +async def test_search_tool_null_omission_keeps_remaining_group_children(operator: Literal["and", "or"]) -> None: + collection = MockCollection() + param = Param("text", str | None, default=None, omit_if_none=True) + fixed = Filter("id", "eq", "one") + tool = create_vector_search_tool( + collection, + filter=FilterGroup(operator, (fixed, Filter("text", "contains_text", param))), + ) + + await tool(query="query") + assert collection.last_search_filter == FilterGroup(operator, (fixed,)) + await tool(query="query", text=None) + assert collection.last_search_filter == FilterGroup(operator, (fixed,)) + await tool(query="query", text="hotel") + assert collection.last_search_filter == FilterGroup(operator, (fixed, Filter("text", "contains_text", "hotel"))) + + +@pytest.mark.parametrize("operator", ["and", "or", "not"]) +@pytest.mark.parametrize("nested", [False, True]) +async def test_search_tool_null_omission_prunes_empty_groups( + operator: Literal["and", "or", "not"], nested: bool +) -> None: + collection = MockCollection() + param = Param("text", str | None, default=None, omit_if_none=True) + fixed = Filter("id", "eq", "one") + group = FilterGroup(operator, (Filter("text", "contains_text", param),)) + tool = create_vector_search_tool( + collection, + filter=FilterGroup("and", (fixed, group)) if nested else group, + ) + expected = FilterGroup("and", (fixed,)) if nested else None + + await tool(query="query") + assert collection.last_search_filter == expected + await tool(query="query", text=None) + assert collection.last_search_filter == expected + await tool(query="query", text="hotel") + resolved_group = FilterGroup(operator, (Filter("text", "contains_text", "hotel"),)) + assert collection.last_search_filter == (FilterGroup("and", (fixed, resolved_group)) if nested else resolved_group) + + +async def test_search_tool_null_omission_is_opt_in_per_parameter() -> None: + collection = MockCollection() + omitted = Param("text", str | None, default=None, omit_if_none=True) + retained = Param("native", str | None, default=None) + tool = create_vector_search_tool( + collection, + filter=FilterGroup( + "and", + (Filter("text", "contains_text", omitted), Filter("text", "provider.nullable", retained)), + ), + ) + + assert not retained.omit_if_none + await tool(query="query", text=None, native=None) + assert collection.last_search_filter == FilterGroup("and", (Filter("text", "provider.nullable", None),)) + await tool(query="query") + assert collection.last_search_filter == FilterGroup("and", (Filter("text", "provider.nullable", None),)) + + strict_tool = create_vector_search_tool(collection, filter=Filter("text", "contains_text", retained)) + with pytest.raises(ValueError, match="requires a value"): + await strict_tool(query="query", native=None) + with pytest.raises(ValueError, match="requires a value"): + await strict_tool(query="query") + with pytest.raises(ValueError, match="requires a value"): + Filter("text", "contains_text", None) + + +def test_search_tool_rejects_conflicting_null_omission_policies() -> None: + with pytest.raises(ValueError, match="conflicting declarations"): + create_vector_search_tool( + MockCollection(), + filter=FilterGroup( + "and", + ( + Filter("text", "contains_text", Param("text", str | None, default=None, omit_if_none=True)), + Filter("text", "provider.nullable", Param("text", str | None, default=None)), + ), + ), + ) + + +@pytest.mark.parametrize("option_name", ["top", "skip"]) +def test_search_tool_rejects_null_omission_for_paging(option_name: Literal["top", "skip"]) -> None: + param = Param(option_name, int | None, default=None, omit_if_none=True, minimum=1, maximum=10) + with pytest.raises(ValueError, match=f"{option_name} Param does not support omit_if_none"): + create_vector_search_tool( + MockCollection(), + top=param if option_name == "top" else 5, + skip=param if option_name == "skip" else 0, + ) + + +async def test_search_operations_snapshot_mutable_filters() -> None: + collection = MockCollection() + collection.mutate_search_filter = True + values = ["one"] + search_filter = Filter("id", "in", values) + + await collection.search("query", filter=search_filter) + + assert values == ["one"] + assert search_filter.value == ["one"] + + tool = create_vector_search_tool(collection, filter=search_filter) + search_filter.field_name = "text" + values.append("two") + await tool(query="query") + + assert isinstance(collection.last_search_filter, Filter) + assert collection.last_search_filter.field_name == "id" + assert collection.last_search_filter.value == ["one", "connector mutation"] + + +@pytest.mark.parametrize("supply_argument", [False, True]) +async def test_search_tool_copies_mutable_param_values_per_invocation(supply_argument: bool) -> None: + class MutatingSearch: + def __init__(self) -> None: + self.seen_values: list[list[str]] = [] + + async def search( + self, + values: Any, + *, + search_type: SearchType = "vector", + filter: Filter | FilterGroup | None = None, + top: int = 3, + skip: int = 0, + **kwargs: Any, + ) -> SearchResults[SearchResponse[Record]]: + assert isinstance(filter, Filter) + received = cast(list[str], filter.value) + self.seen_values.append(list(received)) + received.append("mutated") + return SearchResults([]) + + search = MutatingSearch() + tool = create_vector_search_tool( + cast(SupportsVectorSearch[Record], search), + filter=Filter("tenant_id", "in", Param("tenant_ids", list[str], default=["acme"])), + ) + + supplied = ["acme"] + arguments = {"tenant_ids": supplied} if supply_argument else {} + await tool(query="first", **arguments) + await tool(query="second", **arguments) + + assert search.seen_values == [["acme"], ["acme"]] + assert supplied == ["acme"] + + +async def test_search_tool_deep_copies_supplied_mapping_values() -> None: + class MutatingSearch: + async def search( + self, values: Any, *, filter: Filter | FilterGroup | None = None, **kwargs: Any + ) -> SearchResults[SearchResponse[Record]]: + assert isinstance(filter, Filter) + filter.value["tags"].append("mutated") + return SearchResults([]) + + tool = create_vector_search_tool( + cast(SupportsVectorSearch[Record], MutatingSearch()), + filter=Filter("metadata", "provider.native", Param("metadata", dict[str, list[str]])), + ) + supplied = {"tags": ["original"]} + + await tool(query="query", metadata=supplied) + + assert supplied == {"tags": ["original"]} async def test_runtime_operations_mark_vector_store_feature_usage() -> None: diff --git a/python/samples/02-agents/vector_stores/README.md b/python/samples/02-agents/vector_stores/README.md index 92227f0b1f0..d59041810fd 100644 --- a/python/samples/02-agents/vector_stores/README.md +++ b/python/samples/02-agents/vector_stores/README.md @@ -8,12 +8,17 @@ explicit definition and codecs. Dictionaries use a collection-specific definition; DataFrames and other containers can convert to row dictionaries before calling the batch API. -No database or credentials are needed for these examples. +No database is needed for these examples. The model, format, and direct +in-memory filter samples need no credentials. The search-tool sample loads the +existing Azure AI Search hotel dataset and uses OpenAI for embeddings and the +agent; set `OPENAI_API_KEY` before running it. | File | Demonstrates | |------|--------------| | [`vector_store_models.py`](vector_store_models.py) | Choosing among owned models, third-party model registration, and loose dictionary definitions. | | [`optimized_data_formats.py`](optimized_data_formats.py) | Keeping NumPy vector fields and adapting pandas DataFrames to the batch record API. | +| [`in_memory_filters.py`](in_memory_filters.py) | Direct vector search with `Filter` and `FilterGroup`. | +| [`in_memory_search_tool.py`](in_memory_search_tool.py) | Model-set filter values with native typed `Param` declarations. | The first section shows the two equivalent custom-codec registration forms. `@vectorstoremodel` derives the definition from annotations and registers it; @@ -53,4 +58,6 @@ Run the sample from the `python` directory: ```bash uv run samples/02-agents/vector_stores/vector_store_models.py uv run samples/02-agents/vector_stores/optimized_data_formats.py +uv run samples/02-agents/vector_stores/in_memory_filters.py +uv run samples/02-agents/vector_stores/in_memory_search_tool.py ``` diff --git a/python/samples/02-agents/vector_stores/in_memory_filters.py b/python/samples/02-agents/vector_stores/in_memory_filters.py new file mode 100644 index 00000000000..193584cc41c --- /dev/null +++ b/python/samples/02-agents/vector_stores/in_memory_filters.py @@ -0,0 +1,73 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from dataclasses import dataclass +from typing import Annotated + +from agent_framework import Filter, FilterGroup, InMemoryCollection, VectorStoreField, vectorstoremodel + +""" +This sample demonstrates direct vector searches with portable, data-only filters. + +The in-memory store is process-local and uses a linear scan. It is intended for +tests and development, not as a production vector database. +""" + + +@vectorstoremodel(collection_name="hotels") +@dataclass +class Hotel: + hotel_id: Annotated[str, VectorStoreField("key")] + name: Annotated[str, VectorStoreField("data")] + city: Annotated[str, VectorStoreField("data")] + rating: Annotated[float, VectorStoreField("data")] + amenities: Annotated[list[str], VectorStoreField("data")] + vector: Annotated[ + list[float] | None, + VectorStoreField("vector", dimensions=2, distance_function="cosine_similarity"), + ] = None + + +async def main() -> None: + """Store precomputed vectors and search them with direct filters.""" + collection: InMemoryCollection[str, Hotel] = InMemoryCollection(Hotel) + await collection.ensure_collection_exists() + + # 1. The sample already has vectors, so generation is disabled explicitly. + await collection.upsert( + [ + Hotel("hotel-1", "Harbor View", "Lisbon", 4.8, ["wifi", "pool"], [1.0, 0.1]), + Hotel("hotel-2", "Old Town Rooms", "Lisbon", 4.1, ["wifi"], [0.8, 0.2]), + Hotel("hotel-3", "City Center", "Seattle", 4.7, ["wifi", "gym"], [0.1, 1.0]), + ], + generate_vectors=False, + ) + + # 2. Filter values are ordinary data. No Python source is parsed or executed. + search_filter = FilterGroup( + "and", + ( + Filter("city", "eq", "Lisbon"), + Filter("rating", "between", (4.5, 5.0)), + Filter("amenities", "contains", "pool"), + ), + ) + results = await collection.search( + vector=[1.0, 0.0], + filter=search_filter, + top=5, + ) + + # 3. Search results are consumed asynchronously. + async for result in results: + print(f"{result['record'].name}: {result['score']:.3f}") + + +if __name__ == "__main__": + asyncio.run(main()) + + +""" +Sample output: +Harbor View: 0.995 +""" diff --git a/python/samples/02-agents/vector_stores/in_memory_search_tool.py b/python/samples/02-agents/vector_stores/in_memory_search_tool.py new file mode 100644 index 00000000000..c8072f88cc9 --- /dev/null +++ b/python/samples/02-agents/vector_stores/in_memory_search_tool.py @@ -0,0 +1,189 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import json +import os +from typing import Annotated, Any, Literal +from urllib.request import urlopen + +from agent_framework import ( + Agent, + Filter, + FilterGroup, + InMemoryCollection, + Param, + VectorStoreField, + create_vector_search_tool, + vectorstoremodel, +) +from agent_framework.openai import OpenAIChatClient, OpenAIEmbeddingClient +from dotenv import load_dotenv +from pydantic import BaseModel, ConfigDict, Field + +load_dotenv() + +""" +This sample demonstrates an agent using model-set filter values in a search tool. + +This uses the hotel model and dataset based on the Azure AI Search vector sample dataset. +`Param` values define the exact filter parameters exposed to the model. Their native Python types and +constraints become JSON Schema without requiring another Pydantic parameter model. + +Set `OPENAI_API_KEY` before starting the sample. +""" + +HOTELS_URL = "https://raw.githubusercontent.com/Azure/azure-search-vector-samples/refs/heads/main/data/hotels.json" + + +def to_pascal(name: str) -> str: + """Convert a Python field name to the dataset's PascalCase naming.""" + return "".join(part.capitalize() for part in name.split("_")) + + +class Room(BaseModel): + """Describe one hotel room type.""" + + type: str + description: str + description_fr: str = Field(alias="Description_fr") + base_rate: float + bed_options: str + sleeps_count: int + smoking_allowed: bool + tags: list[str] + + model_config = ConfigDict(alias_generator=to_pascal, populate_by_name=True, extra="ignore") + + +class Address(BaseModel): + """Describe a hotel address.""" + + street_address: str + city: str | None + state_province: str | None + postal_code: str | None + country: str | None + + model_config = ConfigDict(alias_generator=to_pascal, populate_by_name=True, extra="ignore") + + +@vectorstoremodel(collection_name="hotels") +class Hotel(BaseModel): + """Represent one hotel from the Azure AI Search sample dataset.""" + + hotel_id: Annotated[str, VectorStoreField("key")] + hotel_name: Annotated[str | None, VectorStoreField("data")] = None + description: Annotated[str, VectorStoreField("data", is_full_text_indexed=True)] + description_vector: Annotated[ + list[float] | str | None, + VectorStoreField("vector", dimensions=1536, distance_function="cosine_similarity"), + ] = None + description_fr: Annotated[ + str, + Field(alias="Description_fr"), + VectorStoreField("data", is_full_text_indexed=True), + ] + description_fr_vector: Annotated[ + list[float] | str | None, + VectorStoreField("vector", dimensions=1536, distance_function="cosine_similarity"), + ] = None + category: Annotated[str, VectorStoreField("data")] + tags: Annotated[list[str], VectorStoreField("data", is_indexed=True)] + parking_included: Annotated[bool | None, VectorStoreField("data")] = None + last_renovation_date: Annotated[str | None, VectorStoreField("data")] = None + rating: Annotated[float, VectorStoreField("data")] + location: Annotated[dict[str, Any], VectorStoreField("data")] + address: Annotated[Address, VectorStoreField("data")] + rooms: Annotated[list[Room], VectorStoreField("data")] + + model_config = ConfigDict(alias_generator=to_pascal, populate_by_name=True, extra="ignore") + + def model_post_init(self, context: Any) -> None: + """Use the descriptions as embedding inputs when vectors are absent.""" + if self.description_vector is None: + self.description_vector = self.description + if self.description_fr_vector is None: + self.description_fr_vector = self.description_fr + + +def load_hotels() -> list[Hotel]: + """Load the existing Azure AI Search hotel dataset.""" + with urlopen(HOTELS_URL, timeout=60) as response: # nosec B310 - fixed HTTPS sample URL + records = json.loads(response.read()) + return [Hotel.model_validate(record) for record in records] + + +async def main() -> None: + """Create an in-memory hotel search tool and give it to an agent.""" + api_key = os.environ["OPENAI_API_KEY"] + collection: InMemoryCollection[str, Hotel] = InMemoryCollection( + Hotel, + embedding_generator=OpenAIEmbeddingClient( + model="text-embedding-3-small", + api_key=api_key, + ), + ) + await collection.ensure_collection_exists() + + # 1. Load the hotel records. + hotels = await asyncio.to_thread(load_hotels) + await collection.upsert(hotels) + + # 2. Param values become optional model-visible filter arguments. + # When the allowed values are known, use Literal so the tool schema exposes + # them as an enum. + category = Param( + "category", + Literal["Boutique", "Budget", "Extended-Stay", "Luxury", "Resort and Spa", "Suite"], + description="Only return hotels in this category.", + ) + min_rating = Param( + "min_rating", + float, + description="The minimum guest rating.", + minimum=0, + maximum=5, + ) + tool = create_vector_search_tool( + collection, + description="Search the hotel dataset, optionally filtering by category and minimum rating.", + filter=FilterGroup( + "and", + ( + Filter("category", "eq", category), + Filter("rating", "gte", min_rating), + ), + ), + result_mapper=lambda result: ( + f"(hotel_id: {result['record'].hotel_id}) {result['record'].hotel_name} " + f"(rating {result['record'].rating}) - {result['record'].description}. " + f"Address: {result['record'].address.city}, {result['record'].address.country}." + ), + ) + + # 3. The agent chooses whether to supply the exposed category and minimum-rating filters. + async with Agent( + client=OpenAIChatClient( + model="gpt-5.4-nano", + api_key=api_key, + ), + name="HotelAgent", + instructions=( + "Always use the search tool to answer hotel questions. " + "Use category and minimum rating filters when the request provides them. " + "Include the hotel_id in the answer." + ), + tools=[tool], + ) as agent: + result = await agent.run("Find a resort and spa with a rating of at least 4.") + print(result) + + +if __name__ == "__main__": + asyncio.run(main()) + + +""" +Sample output: +The Grand Gaming Resort (hotel_id: 20) is a Resort and Spa rated 4.2. +""" diff --git a/python/samples/AGENTS.md b/python/samples/AGENTS.md index 739329a1250..916e550e07b 100644 --- a/python/samples/AGENTS.md +++ b/python/samples/AGENTS.md @@ -10,7 +10,7 @@ python/samples/ ├── 01-get-started/ # Progressive tutorial (steps 01–07) ├── 02-agents/ # Deep-dive concept samples │ ├── tools/ # Tool patterns (function, approval, schema, etc.) -│ ├── vector_stores/ # Vector model schemas and registration +│ ├── vector_stores/ # Vector models, filters, in-memory search, and tools │ ├── middleware/ # One file per middleware concept │ ├── conversations/ # Thread, storage, suspend/resume │ ├── providers/ # One sub-folder per provider (azure_ai/, openai/, etc.) From 63edba237e46d51951a7f37577162e520b70db16 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 07:12:43 +0000 Subject: [PATCH 78/85] Python: Chore(deps-dev): Bump ruff from 0.16.4 to 0.16.5 in /python (#8064) * Chore(deps-dev): Bump ruff from 0.16.4 to 0.16.5 in /python Bumps [ruff](https://github.com/astral-sh/ruff) from 0.16.4 to 0.16.5. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.16.4...0.16.5) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.16.5 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * Sync lab ruff pin to 0.16.5 and regenerate uv.lock Co-authored-by: moonbox3 <35585003+moonbox3@users.noreply.github.com> * Raise litellm floor to 1.100.0 to fix FastAPI 0.141 import break Co-authored-by: moonbox3 <35585003+moonbox3@users.noreply.github.com> --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: moonbox3 <35585003+moonbox3@users.noreply.github.com> --- python/pyproject.toml | 3 +- python/uv.lock | 224 ++++++++++++++++++++++++++++-------------- 2 files changed, 150 insertions(+), 77 deletions(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index 3d03502029c..de482280bfa 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -60,7 +60,8 @@ test = [ package = false prerelease = "if-necessary-or-explicit" # Security floors for transitive deps; overrides bypass litellm[proxy]'s strict pins. -constraint-dependencies = ["litellm>=1.83.7", "fastapi-sso>=0.19.0"] +# litellm>=1.100.0 drops the `fastapi.dependencies.utils.get_flat_dependant` import removed in FastAPI 0.141.0. +constraint-dependencies = ["litellm>=1.100.0", "fastapi-sso>=0.19.0"] # python-multipart>=0.0.31 overrides litellm[proxy]'s exact pin of <=0.0.27 for security. override-dependencies = ["mcp[ws]>=1.27.0,<2", "uvicorn[standard]>=0.34.0", "python-multipart>=0.0.31"] environments = [ diff --git a/python/uv.lock b/python/uv.lock index a38e3b6ba11..2e9b54d31e9 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -65,7 +65,7 @@ members = [ ] constraints = [ { name = "fastapi-sso", specifier = ">=0.19.0" }, - { name = "litellm", specifier = ">=1.83.7" }, + { name = "litellm", specifier = ">=1.100.0" }, ] overrides = [ { name = "mcp", extras = ["ws"], specifier = ">=1.27.0,<2" }, @@ -2287,61 +2287,58 @@ wheels = [ [[package]] name = "cryptography" -version = "48.0.1" +version = "50.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" }, - { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" }, - { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" }, - { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" }, - { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" }, - { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" }, - { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" }, - { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" }, - { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" }, - { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" }, - { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" }, - { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" }, - { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" }, - { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" }, - { url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" }, - { url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" }, - { url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" }, - { url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" }, - { url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" }, - { url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" }, - { url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" }, - { url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" }, - { url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" }, - { url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" }, - { url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" }, - { url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" }, - { url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498, upload-time = "2026-06-09T22:31:29.154Z" }, - { url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790, upload-time = "2026-06-09T22:31:56.697Z" }, - { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" }, - { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" }, - { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" }, - { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" }, - { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" }, - { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" }, - { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" }, - { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" }, - { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" }, - { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" }, - { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" }, - { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" }, - { url = "https://files.pythonhosted.org/packages/a9/d3/eb4e394e587341fdad09a09101fa76478ead3a78b0ad63e55c22f0d75c02/cryptography-48.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a", size = 3951747, upload-time = "2026-06-09T22:31:23.871Z" }, - { url = "https://files.pythonhosted.org/packages/e0/4a/3f43451b4f858bfceaaaffc649e6e787e8d4fb332a1d443af39ab02cc8f1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd", size = 4641226, upload-time = "2026-06-09T22:31:02.532Z" }, - { url = "https://files.pythonhosted.org/packages/73/4e/855584c2c23b09e4ce2d3b9c30e983e679cd60b068c513c6bbdb91e11782/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c", size = 4668958, upload-time = "2026-06-09T22:32:06.213Z" }, - { url = "https://files.pythonhosted.org/packages/42/3b/d35750e41d803d1e516fd6d6011f065424924da7af1748cef4cc9cb3ede1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9", size = 4640793, upload-time = "2026-06-09T22:32:26.331Z" }, - { url = "https://files.pythonhosted.org/packages/ca/aa/cdb7181fe865285e87e96825aaab239400f1de0c3bfba9bd9769b79f1a92/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92", size = 4668505, upload-time = "2026-06-09T22:31:27.534Z" }, - { url = "https://files.pythonhosted.org/packages/5d/8c/ce3823c06c2804f194f9e64f0d67fa3f4094a39f2bb1a990cd03603af8fc/cryptography-48.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a", size = 3742204, upload-time = "2026-06-09T22:31:34.773Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, + { url = "https://files.pythonhosted.org/packages/c7/27/8d207af749c453ee17ea087340b3f2b4adef75aadd1d277b1b129bdda84e/cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94", size = 3974350, upload-time = "2026-08-25T19:45:26.551Z" }, + { url = "https://files.pythonhosted.org/packages/14/9a/6d3a4d7852e22d657438b7bf51f66102c7d71c0e1fafeec652281d0403e5/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f", size = 4698675, upload-time = "2026-08-25T19:45:28.658Z" }, + { url = "https://files.pythonhosted.org/packages/73/35/5c3717edf9e68a0550ce04e28eab493fe545eccd81742af03f6a75fe260b/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671", size = 4707410, upload-time = "2026-08-25T19:45:30.816Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e0/e786934472e3ac4ecdecc7b129a0ca1a2a40dffdafcf2c3ea9d4397f8def/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e", size = 4698378, upload-time = "2026-08-25T19:45:33.043Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/5b3f53a0b74d122f023476ede40ba5d3e70d5cf475f73b899740d26a4fb2/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6", size = 4706889, upload-time = "2026-08-25T19:45:35.086Z" }, + { url = "https://files.pythonhosted.org/packages/71/44/711e61f7d014be825ef79b285b047292d1bf893732ac1bc030a351fb517f/cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b", size = 3824006, upload-time = "2026-08-25T19:45:37.281Z" }, ] [[package]] @@ -3203,6 +3200,84 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/63/ca511b6f802f28cf3489b280fe77475bcca8de85e81a6299d7916b5b5555/hf_xet-1.6.0-cp38-abi3-win_arm64.whl", hash = "sha256:3dc3e35441ba395006af5aaacc40ef2e603c51ef46c3530b9156185f00935ea3", size = 3859359, upload-time = "2026-08-03T22:33:11.725Z" }, ] +[[package]] +name = "hiredis" +version = "3.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/1e/4729c6fcecb653da6e4877302ed654c24ebb297fe796deee44139bd76179/hiredis-3.4.1.tar.gz", hash = "sha256:2bbb55435506e481d270df8d0b29dd94acb85d11d71df4b8efce23849a4d0bb7", size = 137434, upload-time = "2026-08-07T10:23:01.235Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/a4/704850cddd69db62c9f54ee46be7ebe2261525799980a4963651fe0e9c23/hiredis-3.4.1-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:8f2ccefce627b6caee2e9605ef6eeb7cba50eaed49331789301a678c3c661703", size = 140468, upload-time = "2026-08-07T10:21:06.399Z" }, + { url = "https://files.pythonhosted.org/packages/56/92/5bbdbf8c1a47b98fa015ffe4e42aacf43837ac547dd483dd7bb1cf07cb79/hiredis-3.4.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:8852e54d87cd2e6481c0d0a843d01b0bc46a0300e13afc312228ee4eb4cc470f", size = 74929, upload-time = "2026-08-07T10:21:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/e7/09/52369849b7934f91bd83a47b32327532123cab21090a4ea6800c2a4a625c/hiredis-3.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:67326dd115b5e0bfea5a448f2102357b9957ea0a6d1f15e41916588845b57a2c", size = 71831, upload-time = "2026-08-07T10:21:08.386Z" }, + { url = "https://files.pythonhosted.org/packages/3f/52/f04f600aba3b8eaa8d29c4f671dcc504bdcb226dccb1face84256947ca4e/hiredis-3.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd98896fb410dfc5c47362e5f4af04cd7e179472a57052531b44b043adf360af", size = 305312, upload-time = "2026-08-07T10:21:09.512Z" }, + { url = "https://files.pythonhosted.org/packages/cd/79/9f4f1e0725aa8e751e082653857dca4bc44b026236f0bb991db2fb3a0e36/hiredis-3.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dabc962e38f7cb2e5ed934edaa57777d00d05e432a0ae9a3f22b6d64680fdc7", size = 337802, upload-time = "2026-08-07T10:21:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/8a/61/6ec9c55197fcf8c89f77fe7cdd70b703cdf3c6173e03f583f0056234058d/hiredis-3.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bd12118559e36bd38081c128b4c98f1e96d0a04890770d2750604cdd6a3ca83", size = 348594, upload-time = "2026-08-07T10:21:11.761Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fc/f86d89aee8984fe8a662491e310613287164976e43fa65bdecba0717ede4/hiredis-3.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:606abfff97de808f1bfd7ca2960e4a92176133229490cd33260d6a179dc62b04", size = 310813, upload-time = "2026-08-07T10:21:12.961Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6d/ef80158f5073ed2417d26f6f46d6a91b4080c15d7720b4a160eac9136766/hiredis-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2dd565a51444d4016217c9be9f389a30d641955ae8227eab0c3224497936690", size = 299757, upload-time = "2026-08-07T10:21:14.103Z" }, + { url = "https://files.pythonhosted.org/packages/d5/64/a67103137d24ef82cc3204ec58f8d76a18bbc23e0f240541c708168f00f1/hiredis-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4148ca8973da6dff84628209ebc40722e56463425c9ec3fd18508de0a163f3bb", size = 329325, upload-time = "2026-08-07T10:21:15.391Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2d/5c6653e1a94a3b53b3d3b931c8856b464d82c89af8368a771c6a60090600/hiredis-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e021c48a2f6ff58f04f3344d3dfb6511cfcb120823d6a632af3af608da907cff", size = 330388, upload-time = "2026-08-07T10:21:16.631Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a1/d089a9f4c1883660fe0bf68058ee840eed1ab7c0e5261955c01fef9cd632/hiredis-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:be2cb4733754cda4fa07b8a5ee7f792f341fa830fe28f62be8c6342ffade98d0", size = 309799, upload-time = "2026-08-07T10:21:17.881Z" }, + { url = "https://files.pythonhosted.org/packages/0a/31/8a545f1d3269c2501cd85f6b40bc9076749cccc34dba44ed91d6b9b36fdc/hiredis-3.4.1-cp311-cp311-win32.whl", hash = "sha256:0dd0dda7c9f0e909e1c87a73ec3461ec3bc746962dcdfc3a7cf34d6d1bc57873", size = 38585, upload-time = "2026-08-07T10:21:18.921Z" }, + { url = "https://files.pythonhosted.org/packages/86/ee/b12e8726526f770e6c71ffe6da1b38d50b9387561464e9d3a0067773e2b2/hiredis-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:cc40bae8bca39768eba82820248fcc18ae4d9bf66d8e9c7b51cca40c272863b7", size = 40390, upload-time = "2026-08-07T10:21:19.717Z" }, + { url = "https://files.pythonhosted.org/packages/ff/5e/501f26c2bfb103b059a498aebdb8fec1252ad1df573d732350a32ee230af/hiredis-3.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:48facb01c32fe6234c95f1e5f9d0a730c8e0a184f86962b46369818cf28ba209", size = 36887, upload-time = "2026-08-07T10:21:20.611Z" }, + { url = "https://files.pythonhosted.org/packages/ed/9a/944db038f55c8c3141b29c897b9ef4d930b1bb9ef387d34bc80e1ef78b50/hiredis-3.4.1-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:fd5f86d937ecb5aa1dfed21d774f5ae8f8379eed607b1d9ab0ab6e80c4717981", size = 140842, upload-time = "2026-08-07T10:21:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/61/93/53617e27de296ba7734451d7261532b5c6a03492c3587e428163323980f0/hiredis-3.4.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7630086181d75cd4e377fbbb00ed903619121bcf30b7ae84250366b2717ddebf", size = 75160, upload-time = "2026-08-07T10:21:22.67Z" }, + { url = "https://files.pythonhosted.org/packages/d5/86/e34e08f21b3dec802578768a9cb1d0fcfa82a83e2b69942331058170c3b0/hiredis-3.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c8efc144cc467c62c14cd49d276f1aaec5232ba46300164d59a5fdb68ba77fff", size = 71952, upload-time = "2026-08-07T10:21:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/38/d0/58719bab08a0b9e8e134e18f43eddb5b29e0e8f7edb449ae564312e9323e/hiredis-3.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66953abbda35703727a596bd3a83e86acc4da781e258780c3d85dd6acc1f39f9", size = 306806, upload-time = "2026-08-07T10:21:24.666Z" }, + { url = "https://files.pythonhosted.org/packages/a4/2c/71f3ffe234669c848e92fdad4f79dca1c5e1bd57b547a55fbf41a86bed45/hiredis-3.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b083a1deee1124a7c47baf1d3db85251f4ecd9812a974f586d59ef7d28f6007", size = 339851, upload-time = "2026-08-07T10:21:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f1/971068f1e80ca8b84c188eb1afc4eb4067f8e9e6cb574cc0afd8b737ad0e/hiredis-3.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3e191e6514c54f68a0b3d2b18aa6e73885393be16a31ae74b15c12b544cbaa", size = 351783, upload-time = "2026-08-07T10:21:27.176Z" }, + { url = "https://files.pythonhosted.org/packages/78/b2/4662f0f9218d82d1fb5a58cfa8f6a6742cc53f9c25a36db88667a2ef4510/hiredis-3.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7a2cd31cba425ae954abeafa5dd74552e5ffa61661d3c8098cc66787330c1779", size = 313337, upload-time = "2026-08-07T10:21:28.544Z" }, + { url = "https://files.pythonhosted.org/packages/31/c3/145c5a574ac98f1a32cd0473c1f699ed272de44e240f14b7306ccce4e1c0/hiredis-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:742b4f7ce4b28820ef3fd45c7866f09e07dbf1904895eecd56b482eaa7bd26f5", size = 301233, upload-time = "2026-08-07T10:21:29.755Z" }, + { url = "https://files.pythonhosted.org/packages/0b/09/fa282ff2c5a54ba356312ce49dd276b9dc3db00b8ac6667e93f652347354/hiredis-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:90de946ceac709797efcf3278e3f004f2a60ebd6bb5761bc35d7212d56fc1e5a", size = 331602, upload-time = "2026-08-07T10:21:31.669Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bd/a0102c1394c63e1f2b2550e96c3ab262afeb017ff007effa6d99635d96a6/hiredis-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:404ce858750c6e31d420818d79bceda89869f521c990b01e7ce8fcc95916eb8b", size = 333175, upload-time = "2026-08-07T10:21:32.83Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ca/19c1ebd6b75c5811d77e8d17a2adb9f863c7642a3d5ac129be0d2d3bed78/hiredis-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9f2656e2c11339e7e93df3c0d73c442129fb1381fb709706848f1b49e85677d1", size = 311997, upload-time = "2026-08-07T10:21:33.868Z" }, + { url = "https://files.pythonhosted.org/packages/4a/2b/91f4a42d123b2660a3286e5b853f787e4ae3088b45d53b2c0b0079c6c5c3/hiredis-3.4.1-cp312-cp312-win32.whl", hash = "sha256:e333eb85c9ab16538d43b2e4e1fa564244d3f0c4a8a84e7c640812419b597180", size = 38685, upload-time = "2026-08-07T10:21:34.904Z" }, + { url = "https://files.pythonhosted.org/packages/e3/02/d5df4b12366a15e44857364af52db80c032eac6da4b9ffd644df647fa91b/hiredis-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:b0d11936e377f305024953ae25ba52ae48edc26fe49f47af1e934f642deb3ed6", size = 40413, upload-time = "2026-08-07T10:21:35.963Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f8/f3dadc80ed4f3aeabad0ef309c207ed93b6ce602557ce77ecd73c75b126f/hiredis-3.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:50d821b6195c9a4ba5cda44d950ba6205fdac5a7cf03e1ac4cdf0294f2df886c", size = 36917, upload-time = "2026-08-07T10:21:37.016Z" }, + { url = "https://files.pythonhosted.org/packages/89/29/c24d310627ebb5e5642b02d373619826c893085bf5c4b23ecd18e07b61c7/hiredis-3.4.1-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:7c3632721df2a3addca9a9707f7baa062bb0c004a585873f461b3b7a629c2516", size = 140851, upload-time = "2026-08-07T10:21:37.97Z" }, + { url = "https://files.pythonhosted.org/packages/19/ec/bd25177721047cba5f31976a2ade1cbc042d57d101b3651ab081fca55a0d/hiredis-3.4.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:2b5b4cc3e1806f44f022389ade780aa1054336357defcb87613fe5267470e6f4", size = 75156, upload-time = "2026-08-07T10:21:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bb/d7684fa5a12ac02272bb71a791b59153bb710e192cb8807c231fc03c8f96/hiredis-3.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d856ba70bd97db7cc136ca1dfa72b98044647d08913335949aa70477c8ebfe9a", size = 71957, upload-time = "2026-08-07T10:21:40.003Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d4/e20f512f034834fa203d879b627c5926ab91b2d4e27c08ed6fbbb3a5be92/hiredis-3.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:026639fa97c4b4fcc0f502454287ef1254cc1d067b610cbb958c392c46ff54ae", size = 306702, upload-time = "2026-08-07T10:21:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/6f/cf/4308ba045a27c9f9f607d8f0f7658f68e893641163bfac8951691957c89f/hiredis-3.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d94c41779ae3eaee75c1668f23d26d9eda526055e37cd9052e980c64fb4127cc", size = 339758, upload-time = "2026-08-07T10:21:42.457Z" }, + { url = "https://files.pythonhosted.org/packages/c8/81/f2175323bb9a25a538c43f034f8220ecbe951846e5fe28063121958f605d/hiredis-3.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:464f27b0521375a8179e24f19889d7953a88d22ec00808714a0c78ac8ebffbe7", size = 351696, upload-time = "2026-08-07T10:21:43.796Z" }, + { url = "https://files.pythonhosted.org/packages/59/e5/1835f7fab225ae7225fcf465a1cfb1b5c74035e0e2e2c8e2110a5ed94790/hiredis-3.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54d077e062804fa1eb49d25032bc0cadb085c50a5adc6f6fc43262dde6428471", size = 313239, upload-time = "2026-08-07T10:21:44.917Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c1/7ab8574b87bbc55e979eacd60ca498c76aee7cdb6683e183bdf8431734eb/hiredis-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9186f49f2f45220d1dde7981f7766b7195497d6f3b85617dc0bc519f1e456482", size = 301255, upload-time = "2026-08-07T10:21:46.025Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0b/2a2a06feec8d9013a5f644ab0b4865c15ed65354fac71f56ee5a6c531227/hiredis-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:28c6f40eab7dd56dc63ff0e100e9d5d2759b191615d3134abcb48de5ff1f037a", size = 331723, upload-time = "2026-08-07T10:21:47.365Z" }, + { url = "https://files.pythonhosted.org/packages/be/17/b311e26d402dd23cd259d9f4ce9d47d1a6eb8e9ea71bdf033ae9c082c963/hiredis-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1e52aee6e7c9f97ae6df104388292568ce34ad5f1aae8acc843f4686b4745362", size = 333236, upload-time = "2026-08-07T10:21:48.852Z" }, + { url = "https://files.pythonhosted.org/packages/fb/5f/6abaecbbfe0c9e0436d5e930e35bd125d0ff6207e8b336f034aa838b3fb2/hiredis-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e238e434d22c767b638d591f32532b7b34077267055481fce10bab1a4fa82d39", size = 312041, upload-time = "2026-08-07T10:21:50.063Z" }, + { url = "https://files.pythonhosted.org/packages/6d/28/2596cfe03cea54e5c92bea3ab9084cd3a7f7379583bd21285c0be818ecc9/hiredis-3.4.1-cp313-cp313-win32.whl", hash = "sha256:0ebfbff143596d0b8957e67972ab14591b7427891e2d22b5939ddb1185fe14d2", size = 38690, upload-time = "2026-08-07T10:21:51.254Z" }, + { url = "https://files.pythonhosted.org/packages/23/3a/d51b1565a82e7c2d6da375bc080bbf5a1c5614aced40527321fd0aff17de/hiredis-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:ba678bbf5bd590e5c5b23560e5dcc73b9bbc4ccb4639d1eda1dba669bd8c6cb7", size = 40423, upload-time = "2026-08-07T10:21:52.346Z" }, + { url = "https://files.pythonhosted.org/packages/98/c6/2eec5f964ce65bbf798309ac0b14509aefb479facf824a93a465575011a6/hiredis-3.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:b6bef7f8753b0ab1e2a29781b589e4a64645bbe2753581cd57f32659756ccae2", size = 36932, upload-time = "2026-08-07T10:21:53.237Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9f/2850247842b08ba3f4e9135ce23d7d32fe3bd7dfa300250b7d8d86ce7eb9/hiredis-3.4.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c54721b67df1cbdd0f78e0421b0b9768818109fcadbfa6b4a8d761c2506dd846", size = 140957, upload-time = "2026-08-07T10:21:54.156Z" }, + { url = "https://files.pythonhosted.org/packages/37/f1/52d6a38baafe9ed68d1d2bce3364acc5b0010557e852a5792787696e8ae4/hiredis-3.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:718b86c425c8e2b3505d428ca632f9c9f5ea1c1582edcb76a77aa9c0d0a82580", size = 75201, upload-time = "2026-08-07T10:21:55.191Z" }, + { url = "https://files.pythonhosted.org/packages/eb/04/1d3805b7bddf59c60695f11397e6e7d36cf1e7602fa5aa0ad927fcae09bc/hiredis-3.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d151dd3d715cb62dcc09132e4a8f16c9ec0b0874ab9c6fca3b2cbdc09d52660f", size = 72013, upload-time = "2026-08-07T10:21:56.171Z" }, + { url = "https://files.pythonhosted.org/packages/3a/4b/6fbe51a5ea37366c3ea506a2c999ae2f3414ec78c20958da20c691018f18/hiredis-3.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b59b49cbe1ee36e88a629a6653258cca4a89c3711b5836efde0ef1e011f0ab2", size = 306999, upload-time = "2026-08-07T10:21:57.25Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ab/94c1a638d9698d4c5ace21f52877920a994871156a970364c4d2166c962b/hiredis-3.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:279258dfc81ee6e2235f45e2fc9af00177bdaea5c72eaca6f6bbed56812c1018", size = 340068, upload-time = "2026-08-07T10:21:58.643Z" }, + { url = "https://files.pythonhosted.org/packages/e0/76/ca29c90d39c03d714f15db18ae428e2a664c960e5c5aa9624f086a7c4686/hiredis-3.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98788950e4a973b925a1b5cfe6d74736726732d8785437fcc4b80bbc563d2a47", size = 351581, upload-time = "2026-08-07T10:21:59.973Z" }, + { url = "https://files.pythonhosted.org/packages/95/87/74b10908f1741b0fb5549d97518736989212487d84d6e77717a228eca0f4/hiredis-3.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b980b63a189ed8e2a42274f260430dae2f33a4a61e2f18ce31248909e36bd14a", size = 313244, upload-time = "2026-08-07T10:22:01.878Z" }, + { url = "https://files.pythonhosted.org/packages/c7/fe/4edd184006bb27f84c990e591522dac6c6ccdefd51b12209a2f53878a7cb/hiredis-3.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4e1e92095b511e2a778302b9acd160eceb1f20d49a1c9716a864358fc4ffc236", size = 301264, upload-time = "2026-08-07T10:22:03.287Z" }, + { url = "https://files.pythonhosted.org/packages/da/6f/9069e8da83c5482308803c28b240a2817322a46f097e18f4f65947eacc5c/hiredis-3.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:7eb8b46d2f453030a3514d8ba76edeb92b920b627f883ec3685873c018a96494", size = 332141, upload-time = "2026-08-07T10:22:04.411Z" }, + { url = "https://files.pythonhosted.org/packages/a9/10/a13bcb0f062938bed38b739ba28118bca97b73867777bb34adef857d62ef/hiredis-3.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6fd1472d5e5d82929411ea08d002eb4a8e200558d05b66458b9fcd058214aa33", size = 333373, upload-time = "2026-08-07T10:22:05.69Z" }, + { url = "https://files.pythonhosted.org/packages/9a/6b/dc7a99d829c8593ee02063608661c05d0a932a2c8064c8e8c76f0fcbc67b/hiredis-3.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7b72464f56c3f40f1ae1c784933686c3f0135d15e84fa7eb90166df18577b645", size = 311873, upload-time = "2026-08-07T10:22:07.29Z" }, + { url = "https://files.pythonhosted.org/packages/3a/14/a701b843d53ad3df1342ad537c70efbebf6ed4ea9d8482d50895be17efb6/hiredis-3.4.1-cp314-cp314-win32.whl", hash = "sha256:a5e68f33bfdd542f659066ae7fb4ad37d4634d67fd330903feb0088f01808298", size = 39438, upload-time = "2026-08-07T10:22:08.715Z" }, + { url = "https://files.pythonhosted.org/packages/50/4c/669ec7794d6f562010637cc274513600291b0652bd019a6130ca06bcbb25/hiredis-3.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:7cf4cf0735806049d2ada98ef0ac605e70b6bd303277857f459a8183b38b88c0", size = 40987, upload-time = "2026-08-07T10:22:09.781Z" }, + { url = "https://files.pythonhosted.org/packages/0c/3d/388cb7b58e2a4a964f0f786980f12e3439b215d20de96559e3166a1af2b8/hiredis-3.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:16fb7453720d846168281619021cd3562e4d6252b39ee0dd29610ab26847a0ee", size = 37600, upload-time = "2026-08-07T10:22:10.833Z" }, + { url = "https://files.pythonhosted.org/packages/ae/eb/b5a324259027129ee2a7c47ffa23e1c18a57ae5b26b4ab06575d81d26e56/hiredis-3.4.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fd69048bb3870b962a2e09aff2ebfd0a3a4ee868bd280404c553235c36d43f7f", size = 141835, upload-time = "2026-08-07T10:22:12.01Z" }, + { url = "https://files.pythonhosted.org/packages/f4/94/a1a68fc63fcdc24693b70a52d22de01520773f70320a097f06e4d7c0adff/hiredis-3.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bfd850dbf9c221d4a9e3eae819a91ecc8cdf9843a9ccdbc49cc94fe3f49dec59", size = 75615, upload-time = "2026-08-07T10:22:13.048Z" }, + { url = "https://files.pythonhosted.org/packages/69/be/327f1a953aefa5e0eaa8c7d6ed434cfc80e1906b96177eac7c86009395d2/hiredis-3.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0a70be2b3a2280d48a0c46823455d83a863b8285563177a76667fcd62c686b5c", size = 72473, upload-time = "2026-08-07T10:22:13.985Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e4/6542e44c54c2ca64512daca2ea8c652935f34c53eaf3add585fcffc65d5d/hiredis-3.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4eba0bacd389e350470a883aad5f6733c721c65d408b32ba50b6624025660c4", size = 316006, upload-time = "2026-08-07T10:22:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/8d/24/dbd83e6fe145de363642bbdc6b73e0d230392a4c200b0f74dac441fa14ec/hiredis-3.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c874e1f25fff64a0cd0ac990813950d59c9586094df0ce95cfc0372a6bc750ab", size = 348470, upload-time = "2026-08-07T10:22:16.348Z" }, + { url = "https://files.pythonhosted.org/packages/ec/37/87826800deec76d4fa23b66c71300e11655dcc8bca2a09378536fe582350/hiredis-3.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1bca03bec5515ab7367fb84d5bdc3cd7bae901320eda89e059f1639e3f9e0793", size = 360185, upload-time = "2026-08-07T10:22:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/fd/28/996ee93fe7f3896f2eb21de6baea6499f64753a6e069eb1dafb4209db13d/hiredis-3.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:885220a6a495365961b8124865ccd5ea5ff7d39772fc79265d947befe418cc1b", size = 321719, upload-time = "2026-08-07T10:22:18.743Z" }, + { url = "https://files.pythonhosted.org/packages/ef/43/978846fcbeec794709feed9e987cf879ec58ff4be23b76fc3eb22cc2d30d/hiredis-3.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bfb1f5806a54f643b13065c2c5d05be993401421b8fef309d36f511ed3d13e06", size = 310179, upload-time = "2026-08-07T10:22:19.844Z" }, + { url = "https://files.pythonhosted.org/packages/fe/33/346dbadf9f1d136c6e22fab4c4b88389b62a555dec8e164f5c1597552467/hiredis-3.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:da1c8485246d0ec238d76c6689440c0e1bc28409a46592cda89f2ef1c008f26d", size = 339812, upload-time = "2026-08-07T10:22:21.002Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0f/4c4842a32a3dac6dd8bea6b6ccb2474913500e2576c32361c2248f0b57e3/hiredis-3.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:00073e9b794229daca1089af62e6d2af8ec0a0f5540ced414eede10de2f43dae", size = 341650, upload-time = "2026-08-07T10:22:22.221Z" }, + { url = "https://files.pythonhosted.org/packages/24/d4/29db21f5f89666c17a8f8708ac16e3e11d51de93d0395b76909ef2368828/hiredis-3.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:23667bce8ea8e5c300d4b13e369ef3f8d836b07cfea0dba46b839f1f1bd52548", size = 320722, upload-time = "2026-08-07T10:22:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e8/98e719f8c06e2cc2ff608aab76577077a83c887b79b2b9b8c02f9f2c41e6/hiredis-3.4.1-cp314-cp314t-win32.whl", hash = "sha256:e5377c51a30a09f0e302221dfe93e6f137b0a95f0d45c7756d995408a842627a", size = 39998, upload-time = "2026-08-07T10:22:24.532Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/fe4e49d02322ba07c4793db839c97839114289a1aa49012acec497b55b05/hiredis-3.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5ba1921fc110294a80e28e2cc145edf69f038c263deb22543e787b07394ef5d2", size = 41290, upload-time = "2026-08-07T10:22:25.419Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8d/f27afaabd3fcd3bc2bd66eda3081eb7e7cd637e9f6daa735ee39db220c9b/hiredis-3.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fd46a3fdec76283264e5a564fe38ba813e962bd3af1860970585c242eace683d", size = 38016, upload-time = "2026-08-07T10:22:26.391Z" }, +] + [[package]] name = "hpack" version = "4.2.0" @@ -3858,10 +3933,11 @@ wheels = [ [[package]] name = "litellm" -version = "1.95.0" +version = "1.100.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, + { name = "boto3" }, { name = "click" }, { name = "fastuuid" }, { name = "httpx" }, @@ -3870,24 +3946,20 @@ dependencies = [ { name = "jsonschema" }, { name = "openai" }, { name = "pydantic" }, + { name = "pydantic-settings" }, { name = "python-dotenv" }, { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0e/96/8cdfb9aaf584b57af35a0423c111a1c1264a78b548cebbb5ed96defacdab/litellm-1.95.0.tar.gz", hash = "sha256:0ef126d52c7a559f8353e50d60fd0d5e7e6c8767ad54df25ddaf79b9edca1afc", size = 17513577, upload-time = "2026-08-02T02:52:49.465Z" } +sdist = { url = "https://files.pythonhosted.org/packages/03/ce/1e1ce2558f65244057c0c40c60ade4dfa3767da3522bf1dd8679507ad7ba/litellm-1.100.0.tar.gz", hash = "sha256:ece94e817a453a5b3a9517c03547c428d501cea719edb728c1b260e53f78ea35", size = 17287857, upload-time = "2026-09-06T00:22:46.023Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/c1/470070205a3b36c4d99d95046102468165a55bfd675f32c552566085e746/litellm-1.95.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:4bafa3494d503a3c6c1f2eeb785a2af364d85463c71a92cabaaa761c9227d62a", size = 26425608, upload-time = "2026-08-02T02:52:05.277Z" }, - { url = "https://files.pythonhosted.org/packages/ec/c1/54c79849f4b60aab55772d69099557e49c86c6a43a863d65c1bc985246b3/litellm-1.95.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:4c2a06d2263a07a29228cd3af2b594cf86cd3a4182a7324c517cba88a9415969", size = 26303794, upload-time = "2026-08-02T02:52:08.675Z" }, - { url = "https://files.pythonhosted.org/packages/c5/52/31f2f50063e619b782c74df3ea8835270cce40f94dadaed350cf2f499aa0/litellm-1.95.0-cp311-cp311-win_amd64.whl", hash = "sha256:aac37bb6d2be191bafc0ce590ed06d21dd7e5a37599ffc1af1071899f6c74b73", size = 24921269, upload-time = "2026-08-02T02:52:12.281Z" }, - { url = "https://files.pythonhosted.org/packages/33/d0/ad0272853cc450f8bb4a40a93d206e767e18ac2ff3f91870374e1d9fc090/litellm-1.95.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:cb667f84f08520f32b076e03c7a3fa51bf3f7e8b641dade34ab046bf00314d6b", size = 26421359, upload-time = "2026-08-02T02:52:16.048Z" }, - { url = "https://files.pythonhosted.org/packages/02/c1/4301aa8ef6d2fb0e4a2b8dec973d7c4499f680b26d2e1b77643864235a4b/litellm-1.95.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1bdf7153557cc0851fa9477b137fde476c56d5de92a5778ecfc6c3a75439a4e1", size = 26300401, upload-time = "2026-08-02T02:52:19.501Z" }, - { url = "https://files.pythonhosted.org/packages/7c/3d/6cd087bd541f18d924f17bd8e1bb68a7f9d73d03274c5339f9de563bc992/litellm-1.95.0-cp312-cp312-win_amd64.whl", hash = "sha256:62cc5d834e8223dbd16c9ad0b46c73354b6d67cc7fa0eba2764ce65b3b8c474f", size = 24917446, upload-time = "2026-08-02T02:52:23.119Z" }, - { url = "https://files.pythonhosted.org/packages/fd/47/719785f65b01779cf7568c329430c93b2e7832498deb3deec53bdd106f8d/litellm-1.95.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:9d80a9adc506bfce48145621d6649e3fd428407811eb00211bcce33344054701", size = 26422310, upload-time = "2026-08-02T02:52:26.626Z" }, - { url = "https://files.pythonhosted.org/packages/55/48/06447e1125d7ae31bd24d34d2af2833b15aed79dc5f7e8b45862c1d06af8/litellm-1.95.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:cf014ff515825ad49937b4cdf95616270789311db7841d16702e0a5b1ac5b067", size = 26300935, upload-time = "2026-08-02T02:52:30.032Z" }, - { url = "https://files.pythonhosted.org/packages/45/6f/388f85ebcb4e239dc738cab99307051d5a8a69d907023b0dbb200ee95226/litellm-1.95.0-cp313-cp313-win_amd64.whl", hash = "sha256:c73df441153e585832d4e90e3717d17ae888b269daa71d723336369e81ef884b", size = 24917355, upload-time = "2026-08-02T02:52:33.593Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f6/5b2e944d5c8cea5164e10a28220d9af598792a9c3f25605bcf14946323ef/litellm-1.95.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:c8e712f95764a9a730f3aec9dff8be916973423411ca12a79a35c8bea3f7d5a4", size = 26423318, upload-time = "2026-08-02T02:52:37.629Z" }, - { url = "https://files.pythonhosted.org/packages/ca/f7/d7450f5d3d7b566900067a21bdfafb5cee62bc0bc73b190e8598df1f2e67/litellm-1.95.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:071a63c0e1d949bf7ed5e5c73843cb042a6e00324d3f214b13f8c6b90da6822d", size = 26299965, upload-time = "2026-08-02T02:52:41.577Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d4/c2b7d8b239021c7737a228ab5ffe4f40e4a596ce4629ff3c8899bfd40904/litellm-1.95.0-cp314-cp314-win_amd64.whl", hash = "sha256:c3684dcf16aefe98bd6f11586d60a9a92bb1bf7e85468a6a4ac28a65532fab3e", size = 24920863, upload-time = "2026-08-02T02:52:45.663Z" }, + { url = "https://files.pythonhosted.org/packages/87/92/983d15efffd9bab37ebbff255fb09417814ecd959bd2a2459b8afb6c205d/litellm-1.100.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:098a413e398e2220734cf9c8dd75fb34a38b65b64090619c2869af1fdeaf4ae5", size = 23899430, upload-time = "2026-09-06T00:22:21.941Z" }, + { url = "https://files.pythonhosted.org/packages/59/e1/ea3f868ad6c84b2232f41d5d56adb5f0794ebb1cf32fc0e78effa5842170/litellm-1.100.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:0f87fae695edbca27e5cf970bea52fa405fa9910fdf7c29c963d6413db767299", size = 23552365, upload-time = "2026-09-06T00:22:25.456Z" }, + { url = "https://files.pythonhosted.org/packages/61/50/9439bd3238c9d8c7efcd5165555f175cc8e99e748ba99e8cc7e502787265/litellm-1.100.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a07370d116905485e9ac99679bac991a0a6c81e13c99ca3f007128fbdf2b0082", size = 23693340, upload-time = "2026-09-06T00:22:28.549Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/34a1df3a5fe1db92bf43d3b25af2e5a9c36c54ab6813acef0c31cce2b4e0/litellm-1.100.0-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:8224c8eed9cab3319a88e6665d1275ad8faf21d353b1b22223a6d6115a302ea2", size = 24063361, upload-time = "2026-09-06T00:22:31.913Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d0/4c7e8c8402e5af8c9f55602484c2bc888f0a8fe5c2a01df2a3705ba2139b/litellm-1.100.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e3787fb7ad1f20aebdde7686a85061880bba6550c9d62bfa1cf8df089fe7899b", size = 23766707, upload-time = "2026-09-06T00:22:35.06Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a2/81595fcda1457b777739d265b9383c1061c884af37111223b5fecbf72588/litellm-1.100.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0b7ec93013e18535481cd811b776ee95c6b957b3a9fb44dc53f7c459e7e60e38", size = 24163673, upload-time = "2026-09-06T00:22:38.418Z" }, + { url = "https://files.pythonhosted.org/packages/ce/96/1ee7a3c86c4e9afec528d094b30158cd55b8bbc619e1632ad897bbd1291a/litellm-1.100.0-cp310-abi3-win_amd64.whl", hash = "sha256:c6f2f56808d05d8d2a7766129d958101eafb1a47e30ba5ddcfa900cdbc50af67", size = 23974360, upload-time = "2026-09-06T00:22:42.783Z" }, ] [package.optional-dependencies] @@ -3903,13 +3975,13 @@ proxy = [ { name = "fastapi-sso" }, { name = "granian" }, { name = "gunicorn" }, + { name = "hiredis" }, { name = "inquirerpy" }, { name = "litellm-enterprise" }, { name = "litellm-proxy-extras" }, { name = "mcp", extra = ["ws"] }, { name = "orjson" }, { name = "polars" }, - { name = "pydantic-settings" }, { name = "pyjwt" }, { name = "pynacl" }, { name = "pyroscope-io", marker = "sys_platform != 'win32'" }, @@ -3927,20 +3999,20 @@ proxy = [ [[package]] name = "litellm-enterprise" -version = "0.1.52" +version = "0.1.62" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/75/05/4ec6e20517f3b93d1c10e897d08e54d3fa6b3dcae4e8f4a1324208e892fe/litellm_enterprise-0.1.52.tar.gz", hash = "sha256:c6a3e2ca33b8e384929cd10cb96d47a449cf560c061499d42162e8b4e658a4d0", size = 73731, upload-time = "2026-07-29T23:07:23.106Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/94/86e0927db64cfbe91fb93a35145b879d0cebc1f46efdc6774367d867e4f9/litellm_enterprise-0.1.62.tar.gz", hash = "sha256:2ce78366557335de557ee72ad86c92b2b062b0e07e31dff2139576feda849489", size = 84192, upload-time = "2026-08-30T02:26:49.181Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/7a/c0996a61ca973596924eba9a288e1bd5a2b835465b64ccce284dc1fba623/litellm_enterprise-0.1.52-py3-none-any.whl", hash = "sha256:a6023dc7d8991c9d264cb122dab901ef255e11a13f493663a697128358063d86", size = 140826, upload-time = "2026-07-29T23:07:21.841Z" }, + { url = "https://files.pythonhosted.org/packages/33/0c/b86801eb87540ac98559b8dd470761fdd901b329b9befc24ae11e55f6908/litellm_enterprise-0.1.62-py3-none-any.whl", hash = "sha256:337697160896d52079290ffe8051c3dee450653d271cb29723af6db8546eb7d9", size = 152743, upload-time = "2026-08-30T02:26:47.977Z" }, ] [[package]] name = "litellm-proxy-extras" -version = "0.4.81" +version = "0.4.91" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7f/6b/f9aeb200c2fcd9e74feaa00746fd6dfd4f0f639ad21f566f6e6c49f6d8f3/litellm_proxy_extras-0.4.81.tar.gz", hash = "sha256:b25a324751cdb719c99996e026f62393496c64a5b9cf0dfed235c052fb5ea82a", size = 46850, upload-time = "2026-07-29T23:06:03.927Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/f5/a7d96dee21b19d54407d10edac4578acc049edff9da371f96cbb64ca29de/litellm_proxy_extras-0.4.91.tar.gz", hash = "sha256:5cac5f06f9addd023b5c99a05c2c4c5147eaf799d8f00567e3ee8f04e4595c0b", size = 58092, upload-time = "2026-08-30T02:26:43.467Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/9e/6cdaee3b763cea1f1a879e89c7760861f7470ff0e91b1ff871a17cf61ed3/litellm_proxy_extras-0.4.81-py3-none-any.whl", hash = "sha256:43da81cef78e3124e400ba1eae5a01804d61e674a1330f5a10cea4e4821612b0", size = 132014, upload-time = "2026-07-29T23:06:02.889Z" }, + { url = "https://files.pythonhosted.org/packages/ea/4c/f07dd3f56daee27778b6d98c6a4c215739cc2b4b535d00bf8bbef9b10fc5/litellm_proxy_extras-0.4.91-py3-none-any.whl", hash = "sha256:d19fe53591b0bbd48aff24cb1a166b0c68ff2491469ceec88eaa83c6a364562d", size = 156143, upload-time = "2026-08-30T02:26:41.336Z" }, ] [[package]] @@ -6694,11 +6766,11 @@ wheels = [ [[package]] name = "restrictedpython" -version = "8.4" +version = "8.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/48/50/9302f9a3419ba1956d85b5680c26fd90b32b8064b06c17af31a62e87d936/restrictedpython-8.4.tar.gz", hash = "sha256:68e463c22396fd94606043795ac7dbee05072725f38d0db7e1748da3f54fb274", size = 453323, upload-time = "2026-07-10T06:30:04.886Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/3b/8e41f7cfabbb30b1013ebc7484303d6c87da2906ec432d69dea11d2f7d75/restrictedpython-8.5.tar.gz", hash = "sha256:4ed1269dbe3caa88db650d1af325198a952aeb1451eca05df0cfa65db4466215", size = 455879, upload-time = "2026-08-19T07:02:10.934Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/93/eef88eeb06085a08091f8797b79db786f27600243ddf98d009cfadf145e6/restrictedpython-8.4-py3-none-any.whl", hash = "sha256:ae89a3a4f0eeab314714004117bc49b63b0362ca5bf6a318236c6533fc46de67", size = 30308, upload-time = "2026-07-10T06:30:03.339Z" }, + { url = "https://files.pythonhosted.org/packages/58/57/16ce3c721f5a33317e4110575d5c9976c0c45f7fd96ca2e0adeab06e6026/restrictedpython-8.5-py3-none-any.whl", hash = "sha256:6c70e0a3af13e830d37225788cdc8ab5804a8df4b500c135086eaef34b5c01e0", size = 30962, upload-time = "2026-08-19T07:02:09.553Z" }, ] [[package]] From 0d3ea14fd04ce50e3ecc29b33a8cfb4180f5a9e4 Mon Sep 17 00:00:00 2001 From: Karthik Thota Date: Tue, 8 Sep 2026 07:20:36 +0000 Subject: [PATCH 79/85] Python: feat(core): add max_duration_seconds bound and stop_reason signal to tool loop (#7587) (#7772) * feat(core): add max_duration_seconds bound and stop_reason signal to tool loop (#7587) * fix(core): unify batch limit decision precedence and propagate stop_reason to AgentResponse * test: add missing tests for duration bounds, batch limits, and session persistence * Fix _apply_batch_limit_decision limits bug and Phase 1 test execution - **agent-framework-core**: Refactored _apply_batch_limit_decision to compute perf_counter exactly once per decision point, eliminating the structural fragility where the threshold check and log message used separate clock samples. - **agent-framework-core**: Re-ordered limit checking in Phase 1 to execute before approval response resolution, successfully preventing execution during approved replays when limits are reached. A post-approval check ensures consecutive error limits (ction == stop) remain handled. - **agent-framework-core**: Rewrote 6 tests in est_function_invocation_logic.py that used a fragile call_count mock. The tests now use a mutable clock array that advances directly during the tool execution semantic step, providing true robustness against internal engine refactors. Note: The fallback response trigger (_ensure_function_invocation_limit_fallback_response) remains scoped strictly to the function call limit, preserving pre-existing behavior. Expanding this to cover consecutive errors (ction == stop) or duration timeouts is intentionally left out of scope for this fix. * fix(core): preserve budget start_time across approval resumes, dedupe streaming limit decision, fix double-counted approval calls against max_function_calls * chore: remove CHANGELOG entry, updated centrally on release per review * chore: fix ruff formatting in test_function_invocation_logic.py (exact CI-pinned ruff 0.16.3) * fix(core): remove _agent_framework_stop_reason signal, keep max_duration_seconds only * fix(core): satisfy pre-commit and typing CI checks --- .../_harness/_tool_approval.py | 3 +- .../packages/core/agent_framework/_tools.py | 207 ++++++++-- .../core/test_function_invocation_logic.py | 361 ++++++++++++++++++ 3 files changed, 532 insertions(+), 39 deletions(-) diff --git a/python/packages/core/agent_framework/_harness/_tool_approval.py b/python/packages/core/agent_framework/_harness/_tool_approval.py index 5c3bf7a2c92..f833258776d 100644 --- a/python/packages/core/agent_framework/_harness/_tool_approval.py +++ b/python/packages/core/agent_framework/_harness/_tool_approval.py @@ -385,7 +385,8 @@ async def process(self, context: AgentContext, call_next: Callable[[], Awaitable raise RuntimeError("ToolApprovalMiddleware requires an AgentSession.") state = _get_state(context.session, source_id=self.source_id) - context.client_kwargs.setdefault(_FUNCTION_INVOCATION_BUDGET_STATE_KEY, {}) + session_budget = context.session.state.setdefault(_FUNCTION_INVOCATION_BUDGET_STATE_KEY, {}) + context.client_kwargs.setdefault(_FUNCTION_INVOCATION_BUDGET_STATE_KEY, session_budget) context.messages = self._prepare_inbound_messages(context.messages, state, context.session) await self._drain_auto_approvable_queue(state) if next_queued := self._pop_next_queued_request(state): diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 9b407547608..ac3fc903d99 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1356,6 +1356,17 @@ class FunctionInvocationConfiguration(TypedDict, total=False): parallel tool calls completes, not before. If the model requests 20 parallel calls in a single iteration and the limit is 10, all 20 will execute before the loop stops. + - ``max_duration_seconds``: Wall-clock time budget (in seconds) for the + entire function-invocation loop, measured cumulatively across approval + round-trips. When exceeded, the loop disables further tool calls and + forces the model to produce a text response, reusing the same + graceful-degradation path as ``max_function_calls``. Default is + ``None`` (no time limit). + + This is a **best-effort** limit: the clock is checked *after* each batch + of tool calls completes. The budget includes time spent waiting for human + approval responses — this is intentional so that long-running unattended + sessions are always bounded, even if individual approval steps are slow. - ``max_consecutive_errors_per_request``: How many consecutive errors before abandoning the tool loop for this request. - ``terminate_on_unknown_calls``: Whether to raise an error when the model @@ -1366,11 +1377,13 @@ class FunctionInvocationConfiguration(TypedDict, total=False): function result returned to the model. Note: - ``max_iterations`` and ``max_function_calls`` serve complementary purposes. - ``max_iterations`` caps the number of model round-trips regardless of how - many tools are called per trip. ``max_function_calls`` caps the cumulative - number of individual tool executions regardless of how they are distributed - across iterations. + ``max_iterations``, ``max_function_calls``, and ``max_duration_seconds`` + are complementary limits. ``max_iterations`` caps the number of model + round-trips, ``max_function_calls`` caps the cumulative number of + individual tool executions, and ``max_duration_seconds`` caps cumulative + elapsed wall time. When multiple limits trigger in the same batch, the + stop reason is assigned to whichever condition is detected first in + execution order (duration is checked before the call-count check). Example: .. code-block:: python @@ -1379,14 +1392,17 @@ class FunctionInvocationConfiguration(TypedDict, total=False): client = OpenAIChatClient(api_key="your_api_key") - # Limit to 5 LLM roundtrips and 20 total function executions + # Limit to 5 LLM roundtrips, 20 total function executions, + # and a 30-second wall-clock budget. client.function_invocation_configuration["max_iterations"] = 5 client.function_invocation_configuration["max_function_calls"] = 20 + client.function_invocation_configuration["max_duration_seconds"] = 30.0 """ enabled: bool max_iterations: int max_function_calls: int | None + max_duration_seconds: float | None max_consecutive_errors_per_request: int terminate_on_unknown_calls: bool additional_tools: Sequence[FunctionTool] @@ -1400,6 +1416,7 @@ def normalize_function_invocation_configuration( "enabled": True, "max_iterations": DEFAULT_MAX_ITERATIONS, "max_function_calls": None, + "max_duration_seconds": None, "max_consecutive_errors_per_request": DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST, "terminate_on_unknown_calls": False, "additional_tools": [], @@ -1411,6 +1428,8 @@ def normalize_function_invocation_configuration( raise ValueError("max_iterations must be at least 1.") if normalized["max_function_calls"] is not None and normalized["max_function_calls"] < 1: raise ValueError("max_function_calls must be at least 1 or None.") + if normalized["max_duration_seconds"] is not None and not (normalized["max_duration_seconds"] > 0): + raise ValueError("max_duration_seconds must be greater than 0 or None.") if normalized["max_consecutive_errors_per_request"] < 0: raise ValueError("max_consecutive_errors_per_request must be 0 or more.") return normalized @@ -1901,6 +1920,22 @@ def contents(self) -> list[Content]: """Flatten the ordered result groups for ordinary response processing.""" return [content for result_group in self.result_groups for content in result_group] + @property + def executed_call_count(self) -> int: + """Count of result groups that were actually invoked. + + Excludes groups still deferred on an approval request (the tool body never ran, + unlike a ``UserInputRequiredException`` raised mid-execution, which did run and + still counts) or left as a bare declaration because the batch as a whole was + deferred, so a call is only charged against ``max_function_calls`` once it + actually executes, rather than again when it was merely requested. + """ + return sum( + 1 + for result_group in self.result_groups + if not any(content.type in {"function_approval_request", "function_call"} for content in result_group) + ) + @property def had_errors(self) -> bool: """Whether any execution produced an error result.""" @@ -2801,15 +2836,65 @@ def _disable_tools_at_function_call_limit( options: dict[str, Any], total_function_calls: int, max_function_calls: int | None, -) -> None: +) -> bool: if not _function_call_limit_reached(total_function_calls, max_function_calls): - return + return False logger.info( "Maximum function calls reached (%d/%d). Stopping further function calls for this request.", total_function_calls, max_function_calls, ) options["tool_choice"] = "none" + return True + + +def _clear_budget_state_from_session(invocation_session: AgentSession | None) -> None: + """Remove the per-invocation budget state from session.state once a run fully completes. + + The budget key is left in session.state across approval round-trips so that + cumulative elapsed time is measured correctly. It must be removed on all + terminal exits that are *not* an approval pause (i.e. when no approval + requests are pending), so that a subsequent independent invocation starts + with a clean slate. + """ + if invocation_session is None: + return + # Only remove the budget if there are no approval requests still pending. + tool_state = cast("dict[str, Any]", invocation_session.state.get(_TOOL_APPROVAL_STATE_KEY)) + if isinstance(tool_state, dict): + pending = tool_state.get(_PENDING_APPROVAL_REQUESTS_KEY) + if pending: + return + invocation_session.state.pop(_FUNCTION_INVOCATION_BUDGET_STATE_KEY, None) + + +def _apply_batch_limit_decision( + action: Literal["continue", "return", "stop"], + options: dict[str, Any], + budget_state: dict[str, Any], + total_function_calls: int, + max_function_calls: int | None, + max_duration_seconds: float | None = None, +) -> None: + if action != "continue" and action != "stop": + return + if max_duration_seconds is not None: + elapsed = perf_counter() - budget_state["start_time"] + if elapsed >= max_duration_seconds: + logger.info( + "Maximum duration reached (%.2fs / %.2fs). Stopping further function calls for this request.", + elapsed, + max_duration_seconds, + ) + options["tool_choice"] = "none" + budget_state["truncated"] = True + + if action == "stop": + options["tool_choice"] = "none" + budget_state["truncated"] = True + else: + if _disable_tools_at_function_call_limit(options, total_function_calls, max_function_calls): + budget_state["truncated"] = True def _record_function_calls( @@ -2982,7 +3067,7 @@ async def _resolve_approval_responses( execution_result_groups: list[list[Content]] = [] should_terminate = False reached_error_limit = False - if responses_to_execute: + if responses_to_execute and not (options and options.get("tool_choice") == "none"): try: execution = await execute_function_calls( function_calls=responses_to_execute, @@ -3069,7 +3154,7 @@ async def _process_model_function_calls( processing_result = _handle_function_call_results( response=response, execution_results=execution.contents, - function_call_count=len(execution.result_groups), + function_call_count=execution.executed_call_count, function_call_messages=function_call_messages, errors_in_a_row=errors_in_a_row, had_errors=execution.had_errors, @@ -3256,6 +3341,7 @@ async def _get_response_with_function_invocation( errors_in_a_row = 0 total_function_calls = int(budget_state.get("total_function_calls", 0) or 0) max_function_calls = self.function_invocation_configuration.get("max_function_calls") + max_duration_seconds = self.function_invocation_configuration.get("max_duration_seconds") prepared_messages = _copy_messages_for_function_invocation(messages) function_call_messages: list[Message] = [] response: ChatResponse[Any] | None = None @@ -3274,6 +3360,16 @@ async def settle_approval_replay_calls(function_calls: Sequence[Content]) -> Non invocation_session=invocation_session, ) + # Apply limit decisions early to prevent execution during replay if limits are already breached. + _apply_batch_limit_decision( + "continue", + options, + budget_state, + total_function_calls, + max_function_calls, + max_duration_seconds, + ) + # Phase 1: resolve inbound approvals before consuming another model iteration. approval_processing = await _resolve_approval_responses( prepared_messages=prepared_messages, @@ -3294,11 +3390,18 @@ async def settle_approval_replay_calls(function_calls: Sequence[Content]) -> Non if approval_processing.action == "return": response = ChatResponse(messages=list(function_call_messages)) response.usage_details = aggregated_usage + _clear_budget_state_from_session(invocation_session) return _clear_internal_conversation_id(response) - if approval_processing.action == "stop": - options["tool_choice"] = "none" - else: - _disable_tools_at_function_call_limit(options, total_function_calls, max_function_calls) + + if options.get("tool_choice") != "none": + _apply_batch_limit_decision( + approval_processing.action, + options, + budget_state, + total_function_calls, + max_function_calls, + max_duration_seconds, + ) # Phase 2: alternate model turns and local execution until a terminal response or safety limit is reached. for attempt_idx in range(attempt_start, max_iterations): @@ -3314,9 +3417,7 @@ async def settle_approval_replay_calls(function_calls: Sequence[Content]) -> Non client_kwargs=request_kwargs, ), ) - if options.get("tool_choice") == "none" and _function_call_limit_reached( - total_function_calls, max_function_calls - ): + if options.get("tool_choice") == "none" and budget_state.get("truncated"): _ensure_function_invocation_limit_fallback_response(response) aggregated_usage = add_usage_details(aggregated_usage, response.usage_details) self._update_function_invocation_continuation_state( @@ -3359,11 +3460,16 @@ async def settle_approval_replay_calls(function_calls: Sequence[Content]) -> Non ) if function_processing.action == "return": response.usage_details = aggregated_usage + _clear_budget_state_from_session(invocation_session) return _clear_internal_conversation_id(response) - if function_processing.action == "stop": - options["tool_choice"] = "none" - else: - _disable_tools_at_function_call_limit(options, total_function_calls, max_function_calls) + _apply_batch_limit_decision( + function_processing.action, + options, + budget_state, + total_function_calls, + max_function_calls, + max_duration_seconds, + ) errors_in_a_row = function_processing.errors_in_a_row _reset_required_tool_choice(options) _prepare_messages_for_next_iteration(prepared_messages, response) @@ -3396,6 +3502,7 @@ async def settle_approval_replay_calls(function_calls: Sequence[Content]) -> Non ) response.usage_details = aggregated_usage _prepend_function_call_messages(response, function_call_messages) + _clear_budget_state_from_session(invocation_session) return _clear_internal_conversation_id(response) async def _stream_response_with_function_invocation( @@ -3418,6 +3525,7 @@ async def _stream_response_with_function_invocation( errors_in_a_row = 0 total_function_calls = int(budget_state.get("total_function_calls", 0) or 0) max_function_calls = self.function_invocation_configuration.get("max_function_calls") + max_duration_seconds = self.function_invocation_configuration.get("max_duration_seconds") prepared_messages = _copy_messages_for_function_invocation(messages) response: ChatResponse[Any] | None = None max_iterations = self.function_invocation_configuration.get("max_iterations", DEFAULT_MAX_ITERATIONS) @@ -3434,6 +3542,16 @@ async def settle_approval_replay_calls(function_calls: Sequence[Content]) -> Non invocation_session=invocation_session, ) + # Apply limit decisions early to prevent execution during replay if limits are already breached. + _apply_batch_limit_decision( + "continue", + options, + budget_state, + total_function_calls, + max_function_calls, + max_duration_seconds, + ) + # Phase 1: resolve and emit inbound approval outcomes before opening another provider stream. approval_processing = await _resolve_approval_responses( prepared_messages=prepared_messages, @@ -3454,10 +3572,16 @@ async def settle_approval_replay_calls(function_calls: Sequence[Content]) -> Non yield update if approval_processing.action == "return": return - if approval_processing.action == "stop": - options["tool_choice"] = "none" - else: - _disable_tools_at_function_call_limit(options, total_function_calls, max_function_calls) + + if options.get("tool_choice") != "none": + _apply_batch_limit_decision( + approval_processing.action, + options, + budget_state, + total_function_calls, + max_function_calls, + max_duration_seconds, + ) # Phase 2: stream each model turn, finalize it, execute its calls, then advance the transcript. for attempt_idx in range(attempt_start, max_iterations): @@ -3474,10 +3598,7 @@ async def settle_approval_replay_calls(function_calls: Sequence[Content]) -> Non ), ) await inner_stream - drop_unexecutable_calls = options.get("tool_choice") == "none" and _function_call_limit_reached( - total_function_calls, - max_function_calls, - ) + drop_unexecutable_calls = options.get("tool_choice") == "none" and budget_state.get("truncated") streamed_identities_by_call_id: dict[str, tuple[str, str]] = {} streamed_names_by_call_id: dict[str, str] = {} last_streamed_identity: tuple[str, str] | None = None @@ -3540,11 +3661,8 @@ async def settle_approval_replay_calls(function_calls: Sequence[Content]) -> Non yield update response = await inner_stream.get_final_response() - function_call_limit_reached = options.get("tool_choice") == "none" and _function_call_limit_reached( - total_function_calls, max_function_calls - ) fallback_added = False - if function_call_limit_reached: + if options.get("tool_choice") == "none" and budget_state.get("truncated"): fallback_added = _ensure_function_invocation_limit_fallback_response(response) self._update_function_invocation_continuation_state( request_kwargs, @@ -3560,6 +3678,7 @@ async def settle_approval_replay_calls(function_calls: Sequence[Content]) -> Non ): if fallback_added: yield _function_invocation_limit_fallback_update() + _clear_budget_state_from_session(invocation_session) return try: @@ -3595,12 +3714,17 @@ async def settle_approval_replay_calls(function_calls: Sequence[Content]) -> Non ) for update in function_processing.streaming_updates: yield update - if function_processing.action == "stop": - options["tool_choice"] = "none" - elif function_processing.action != "continue": + if function_processing.action != "continue" and function_processing.action != "stop": + # "return" action: model produced a terminal response. return - else: - _disable_tools_at_function_call_limit(options, total_function_calls, max_function_calls) + _apply_batch_limit_decision( + function_processing.action, + options, + budget_state, + total_function_calls, + max_function_calls, + max_duration_seconds, + ) _reset_required_tool_choice(options) _prepare_messages_for_next_iteration(prepared_messages, response) @@ -3638,6 +3762,7 @@ async def settle_approval_replay_calls(function_calls: Sequence[Content]) -> Non ) if fallback_added: yield _function_invocation_limit_fallback_update() + _clear_budget_state_from_session(invocation_session) @overload def get_response( @@ -3729,9 +3854,14 @@ def get_response( budget_state: dict[str, Any] = ( cast(dict[str, Any], raw_budget_state) if isinstance(raw_budget_state, dict) else {} ) + # Record the start time once for the full logical run (including approval round-trips). + # setdefault preserves the original timestamp across approval re-entries so that + # max_duration_seconds measures cumulative elapsed time, not just the current segment. + budget_state.setdefault("start_time", perf_counter()) max_errors = self.function_invocation_configuration.get( "max_consecutive_errors_per_request", DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST ) + additional_function_arguments = ( dict(function_invocation_kwargs) if function_invocation_kwargs is not None else {} ) @@ -3791,6 +3921,7 @@ def get_response( ) response_format = mutable_options.get("response_format") + return ResponseStream( self._stream_response_with_function_invocation( super_get_response=super_get_response, 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 5a2ba3e7197..39204afcdc1 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -6912,3 +6912,364 @@ def a(x: int) -> int: # endregion + + +# region max_duration_seconds + + +@pytest.mark.parametrize("max_iterations", [10]) +async def test_max_duration_seconds_non_streaming_triggers_graceful_degradation( + chat_client_base: SupportsChatGetResponse, +): + """When max_duration_seconds is exceeded mid-loop, tools are disabled and the model + produces a final text response (same graceful-degradation path as max_function_calls).""" + exec_counter = 0 + + @tool(name="step", approval_mode="never_require") + def step_func() -> str: + nonlocal exec_counter + exec_counter += 1 + current_time[0] = 10.0 # Advance time so the next check triggers expiration + return "done" + + chat_client_base.function_invocation_configuration["max_duration_seconds"] = 5.0 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse( + messages=Message( + role="assistant", + contents=[Content.from_function_call(call_id="c1", name="step", arguments="{}")], + ) + ), + # model would call again, but duration check fires first + ChatResponse( + messages=Message( + role="assistant", + contents=[Content.from_function_call(call_id="c2", name="step", arguments="{}")], + ) + ), + ChatResponse(messages=Message(role="assistant", contents=["giving up"])), + ] + + current_time = [0.0] + + def fake_perf_counter() -> float: + return current_time[0] + + from unittest.mock import patch + + with patch("agent_framework._tools.perf_counter", side_effect=fake_perf_counter): + await chat_client_base.get_response( + [Message(role="user", contents=["go"])], options={"tool_choice": "auto", "tools": [step_func]} + ) + + # Only first tool call executes; duration fires before second iteration's tool call + assert exec_counter == 1 + + +@pytest.mark.parametrize("max_iterations", [10]) +async def test_max_duration_seconds_streaming_triggers_graceful_degradation( + chat_client_base: SupportsChatGetResponse, +): + """max_duration_seconds also triggers graceful degradation in the streaming path.""" + exec_counter = 0 + + @tool(name="step", approval_mode="never_require") + def step_func() -> str: + nonlocal exec_counter + exec_counter += 1 + current_time[0] = 10.0 # Advance time so the next check triggers expiration + return "done" + + chat_client_base.function_invocation_configuration["max_duration_seconds"] = 5.0 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + chat_client_base.streaming_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + [ + ChatResponseUpdate( + contents=[Content.from_function_call(call_id="s1", name="step", arguments="{}")], + role="assistant", + finish_reason="tool_calls", + ) + ], + # duration fires after iteration 1; second batch never executes tool + [ + ChatResponseUpdate( + contents=[Content.from_function_call(call_id="s2", name="step", arguments="{}")], + role="assistant", + finish_reason="tool_calls", + ) + ], + ] + + current_time = [0.0] + + def fake_perf_counter() -> float: + return current_time[0] + + from unittest.mock import patch + + with patch("agent_framework._tools.perf_counter", side_effect=fake_perf_counter): + stream = chat_client_base.get_response( + [Message(role="user", contents=["go"])], + stream=True, + options={"tool_choice": "auto", "tools": [step_func]}, + ) + async for _ in stream: + pass + await stream.get_final_response() + + assert exec_counter == 1 + + +def test_normalize_rejects_non_positive_max_duration_seconds(): + """normalize_function_invocation_configuration raises ValueError for max_duration_seconds <= 0.""" + from agent_framework import normalize_function_invocation_configuration + + with pytest.raises(ValueError, match="max_duration_seconds"): + normalize_function_invocation_configuration({"max_duration_seconds": 0.0}) + + with pytest.raises(ValueError, match="max_duration_seconds"): + normalize_function_invocation_configuration({"max_duration_seconds": -1.0}) + + # None and positive values are accepted. + cfg = normalize_function_invocation_configuration({"max_duration_seconds": None}) + assert cfg["max_duration_seconds"] is None + + cfg = normalize_function_invocation_configuration({"max_duration_seconds": 30.0}) + assert cfg["max_duration_seconds"] == 30.0 + + +# endregion + + +def test_max_duration_seconds_rejects_nan(): + from agent_framework._tools import normalize_function_invocation_configuration + + with pytest.raises(ValueError, match="max_duration_seconds must be greater than 0 or None"): + normalize_function_invocation_configuration({"max_duration_seconds": float("nan")}) + + +async def test_phase1_duration_expiry_prevents_approval_execution(chat_client_base: SupportsChatGetResponse): + from agent_framework._harness._tool_approval import ToolApprovalMiddleware + from agent_framework._sessions import AgentSession + + tool_executed = False + + @tool(name="op", approval_mode="always_require") + def op() -> str: + nonlocal tool_executed + tool_executed = True + return "done" + + chat_client_base.function_invocation_configuration["max_duration_seconds"] = 1.0 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + call = Content.from_function_call(call_id="op1", name="op", arguments="{}") + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse(messages=[Message(role="assistant", contents=[call])], finish_reason="tool_calls"), + ChatResponse( + messages=[Message(role="assistant", contents=[Content.from_text("fallback")])], finish_reason="stop" + ), + ] + + current_time = [0.0] + + def fake_perf_counter() -> float: + return current_time[0] + + session = AgentSession() + middleware = ToolApprovalMiddleware() + agent = Agent(client=chat_client_base, tools=[op], middleware=[middleware]) + + from unittest.mock import patch + + with patch("agent_framework._tools.perf_counter", side_effect=fake_perf_counter): + # First run: getting the approval request. Time is 0.0, so duration is not exceeded. + first_response = await agent.run("Go", session=session) + + # Find the approval request in the response + approval_request = next( + c for c in first_response.messages[-1].contents if c.type == "function_approval_request" + ) + + # Now advance time to exceed max_duration_seconds (1.0s limit) + current_time[0] = 10.0 + + # Second run: user provides the approval response, but duration is exceeded. + resume_message = Message(role="user", contents=[approval_request.to_function_approval_response(approved=True)]) + await agent.run(resume_message, session=session) + + # The tool should NOT have executed — the phase 1 check should have blocked it. + assert not tool_executed + + +@pytest.mark.parametrize("streaming", [False, True]) +async def test_duration_expiry_drops_unexecutable_provider_call( + chat_client_base: SupportsChatGetResponse, + streaming: bool, +): + @tool(name="op") + def op() -> str: + return "done" + + chat_client_base.function_invocation_configuration["max_duration_seconds"] = 1.0 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + call = Content.from_function_call(call_id="op1", name="op", arguments="{}") + if streaming: + chat_client_base.streaming_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + [ChatResponseUpdate(contents=[call], role="assistant", finish_reason="tool_calls")], + [ChatResponseUpdate(contents=[call], role="assistant", finish_reason="tool_calls")], + [ChatResponseUpdate(contents=[Content.from_text("fallback")], role="assistant", finish_reason="stop")], + ] + else: + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse(messages=[Message(role="assistant", contents=[call])], finish_reason="tool_calls"), + ChatResponse(messages=[Message(role="assistant", contents=[call])], finish_reason="tool_calls"), + ChatResponse( + messages=[Message(role="assistant", contents=[Content.from_text("fallback")])], finish_reason="stop" + ), + ] + + agent = Agent(client=chat_client_base, tools=[op]) + current_time = [0.0] + + def fake_perf_counter() -> float: + res = current_time[0] + current_time[0] = 10.0 # Advance time so the next check triggers expiration + return res + + from unittest.mock import patch + + with patch("agent_framework._tools.perf_counter", side_effect=fake_perf_counter): + if streaming: + stream = agent.run("Go", stream=True) + async for _ in stream: + pass + response = await stream.get_final_response() + else: + response = await agent.run("Go") + + # Duration has already expired before the first call, so tools are disabled from the + # start and the loop terminates on the first (tool-less) response without executing "op". + assert response.text == "I broke out of the function invocation loop..." + assert not any(content.type == "function_call" for message in response.messages for content in message.contents) + + +async def test_session_budget_state_persists_during_approval_and_cleans_up_on_completion( + chat_client_base: SupportsChatGetResponse, +): + from agent_framework._harness._tool_approval import ToolApprovalMiddleware + from agent_framework._sessions import AgentSession + from agent_framework._tools import _FUNCTION_INVOCATION_BUDGET_STATE_KEY + + @tool(name="op", approval_mode="always_require") + def op() -> str: + return "done" + + chat_client_base.function_invocation_configuration["max_duration_seconds"] = 100.0 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + call = Content.from_function_call(call_id="op1", name="op", arguments="{}") + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse(messages=[Message(role="assistant", contents=[call])], finish_reason="tool_calls"), + ChatResponse( + messages=[Message(role="assistant", contents=[Content.from_text("fallback")])], finish_reason="stop" + ), + ] + + session = AgentSession() + middleware = ToolApprovalMiddleware() + agent = Agent(client=chat_client_base, tools=[op], middleware=[middleware]) + + current_time = [0.0] + + def fake_perf_counter() -> float: + res = current_time[0] + current_time[0] = 10.0 # Advance time so the next check triggers expiration + return res + + from unittest.mock import patch + + with patch("agent_framework._tools.perf_counter", side_effect=fake_perf_counter): + first_response = await agent.run("Go", session=session) + assert any(c.type == "function_approval_request" for c in first_response.messages[-1].contents) + + assert _FUNCTION_INVOCATION_BUDGET_STATE_KEY in session.state + assert "start_time" in session.state[_FUNCTION_INVOCATION_BUDGET_STATE_KEY] + + approval_request = next( + c for c in first_response.messages[-1].contents if c.type == "function_approval_request" + ) + resume_message = Message(role="user", contents=[approval_request.to_function_approval_response(approved=True)]) + + await agent.run(resume_message, session=session) + + assert _FUNCTION_INVOCATION_BUDGET_STATE_KEY not in session.state + + +async def test_plain_session_multiturn_has_isolated_budget_state_per_invocation( + chat_client_base: SupportsChatGetResponse, +): + from agent_framework._sessions import AgentSession + from agent_framework._tools import _FUNCTION_INVOCATION_BUDGET_STATE_KEY + + @tool(name="op") + def op() -> str: + return "done" + + chat_client_base.function_invocation_configuration["max_duration_seconds"] = 100.0 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse(messages=[Message(role="assistant", contents=[Content.from_text("first")])], finish_reason="stop"), + ChatResponse( + messages=[Message(role="assistant", contents=[Content.from_text("second")])], finish_reason="stop" + ), + ] + + session = AgentSession() + agent = Agent(client=chat_client_base, tools=[op]) + + await agent.run("Go", session=session) + assert _FUNCTION_INVOCATION_BUDGET_STATE_KEY not in session.state + + await agent.run("Go again", session=session) + assert _FUNCTION_INVOCATION_BUDGET_STATE_KEY not in session.state + + +async def test_streaming_pending_approval_survives_budget_state_pop(chat_client_base: SupportsChatGetResponse): + from agent_framework._harness._tool_approval import ToolApprovalMiddleware + from agent_framework._sessions import AgentSession + from agent_framework._tools import _FUNCTION_INVOCATION_BUDGET_STATE_KEY + + @tool(name="op", approval_mode="always_require") + def op() -> str: + return "done" + + chat_client_base.function_invocation_configuration["max_duration_seconds"] = 100.0 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + call = Content.from_function_call(call_id="op1", name="op", arguments="{}") + chat_client_base.streaming_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + [ChatResponseUpdate(contents=[call], role="assistant", finish_reason="tool_calls")], + [ChatResponseUpdate(contents=[Content.from_text("fallback")], role="assistant", finish_reason="stop")], + ] + + session = AgentSession() + middleware = ToolApprovalMiddleware() + agent = Agent(client=chat_client_base, tools=[op], middleware=[middleware]) + + from unittest.mock import patch + + with patch("agent_framework._tools.perf_counter", return_value=0.0): + stream = agent.run("Go", session=session, stream=True) + + async for _ in stream: + pass + + first_response = await stream.get_final_response() + assert any(c.type == "function_approval_request" for c in first_response.messages[-1].contents) + assert _FUNCTION_INVOCATION_BUDGET_STATE_KEY in session.state + + approval_request = next( + c for c in first_response.messages[-1].contents if c.type == "function_approval_request" + ) + resume_message = Message(role="user", contents=[approval_request.to_function_approval_response(approved=True)]) + + stream2 = agent.run(resume_message, session=session, stream=True) + async for _ in stream2: + pass + await stream2.get_final_response() + + assert _FUNCTION_INVOCATION_BUDGET_STATE_KEY not in session.state From 540ad6baf0959aced104b1ba355f80529926312a Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Tue, 8 Sep 2026 07:56:50 +0000 Subject: [PATCH 80/85] Fix missing native issue types for bug and feature reports (#8144) * Fix missing native issue types for bug and feature reports * Preserve labeling when issue type fallback fails --- .github/scripts/set_missing_issue_type.js | 25 +++++ .github/tests/test_set_missing_issue_type.js | 97 ++++++++++++++++++++ .github/workflows/label-issues.yml | 13 ++- 3 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/set_missing_issue_type.js create mode 100644 .github/tests/test_set_missing_issue_type.js diff --git a/.github/scripts/set_missing_issue_type.js b/.github/scripts/set_missing_issue_type.js new file mode 100644 index 00000000000..a59b4a0ef27 --- /dev/null +++ b/.github/scripts/set_missing_issue_type.js @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +/** Set a missing native issue type from the standard bug/feature title prefix. */ +async function setMissingIssueType({ github, context, core }) { + const params = { ...context.repo, issue_number: context.issue.number }; + // Read current metadata: a maintainer may have classified or renamed the + // issue since the opened/reopened event was queued. This preserves types + // observed at read time; GitHub does not document conditional issue updates, + // so a manual edit between this GET and the PATCH can still race. + const { data: issue } = await github.rest.issues.get(params); + if (issue.type != null) { + return; + } + + const match = issue.title?.match(/^\s*(?:(?:Python|\.NET):\s*)?\[(Bug|Feature)\]:/i); + if (!match) { + return; + } + + const type = match[1].toLowerCase() === 'bug' ? 'Bug' : 'Feature'; + await github.rest.issues.update({ ...params, type }); + core.info(`Set missing issue type to ${type} for #${context.issue.number}.`); +} + +module.exports = setMissingIssueType; diff --git a/.github/tests/test_set_missing_issue_type.js b/.github/tests/test_set_missing_issue_type.js new file mode 100644 index 00000000000..70f4ce4e578 --- /dev/null +++ b/.github/tests/test_set_missing_issue_type.js @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft. All rights reserved. + +const { it } = require('node:test'); +const assert = require('node:assert/strict'); +const { readFileSync } = require('node:fs'); +const path = require('node:path'); +const setMissingIssueType = require('../scripts/set_missing_issue_type.js'); + +function createMocks(issue) { + const updates = []; + const context = { + repo: { owner: 'microsoft', repo: 'agent-framework' }, + issue: { number: 123 }, + // An opened event can be stale by the time the workflow runs. + payload: { issue: { title: 'Python: [Bug]: original title', type: null } }, + }; + const github = { rest: { issues: { + get: async (params) => { + assert.deepEqual(params, { ...context.repo, issue_number: 123 }); + return { data: issue }; + }, + update: async (params) => { updates.push(params); }, + } } }; + return { github, context, core: { info() {} }, updates }; +} + +for (const [title, type] of [ + ['Python: [Bug]: broken', 'Bug'], + ['.NET: [Bug]: broken', 'Bug'], + ['[Bug]: broken', 'Bug'], + ['[Feature]: new capability', 'Feature'], + ['Python: [Feature]: new capability', 'Feature'], + ['.NET: [Feature]: new capability', 'Feature'], + [' python: [bug]: broken', 'Bug'], +]) { + it(`sets ${type} for ${title}`, async () => { + const mocks = createMocks({ title, type: null }); + await setMissingIssueType(mocks); + assert.deepEqual(mocks.updates, [{ ...mocks.context.repo, issue_number: 123, type }]); + }); +} + +for (const type of [{ name: 'Bug' }, { name: 'Feature' }, { name: 'Task' }]) { + it(`preserves an existing ${type.name} despite a stale blank event`, async () => { + const mocks = createMocks({ title: 'Python: [Bug]: broken', type }); + await setMissingIssueType(mocks); + assert.deepEqual(mocks.updates, []); + }); +} + +for (const title of ['Python: [Question]: help', 'Discuss [Bug]: title format', 'Bug report', '', null]) { + it(`leaves an unrecognized current title unchanged: ${title}`, async () => { + const mocks = createMocks({ title, type: null }); + await setMissingIssueType(mocks); + assert.deepEqual(mocks.updates, []); + }); +} + +it('propagates API failures so the workflow reports the failure', async () => { + const mocks = createMocks({ title: '[Bug]: broken', type: null }); + mocks.github.rest.issues.update = async () => { throw new Error('API unavailable'); }; + await assert.rejects(setMissingIssueType(mocks), /API unavailable/); +}); + +for (const failingMethod of ['get', 'update']) { + it(`finishes workflow label assignment before a fallback ${failingMethod} failure`, async () => { + const workflow = readFileSync(path.join(__dirname, '../workflows/label-issues.yml'), 'utf8'); + // Execute the actual github-script steps in workflow order, with API mocks. + const scripts = [...workflow.matchAll(/^ script: \|\n((?:^ .*\n|^\n)+)/gm)] + .map((match) => match[1].replace(/^ /gm, '')); + assert.equal(scripts.length, 2); + const mocks = createMocks({ title: 'Python: [Bug]: broken', type: null }); + const calls = []; + mocks.github.rest.issues.addLabels = async ({ labels }) => { + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(labels, ['triage', 'python']); + calls.push('labels completed'); + }; + mocks.github.rest.issues[failingMethod] = async () => { + calls.push('fallback failed'); + throw new Error('API unavailable'); + }; + const requireScript = (name) => name.endsWith('check_team_membership.js') + ? async () => ({ isTeamMember: false }) + : setMissingIssueType; + const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; + const runSteps = async () => { + for (const script of scripts) { + await new AsyncFunction('github', 'context', 'core', 'require', script)( + mocks.github, mocks.context, mocks.core, requireScript, + ); + } + }; + await assert.rejects(runSteps(), /API unavailable/); + assert.deepEqual(calls, ['labels completed', 'fallback failed']); + }); +} diff --git a/.github/workflows/label-issues.yml b/.github/workflows/label-issues.yml index d514c7a38f0..5399fe09ae8 100644 --- a/.github/workflows/label-issues.yml +++ b/.github/workflows/label-issues.yml @@ -22,6 +22,7 @@ jobs: sparse-checkout: | .github/actions/github-app-token .github/scripts/check_team_membership.js + .github/scripts/set_missing_issue_type.js fetch-depth: 1 persist-credentials: false @@ -119,7 +120,7 @@ jobs: // Add the labels to the issue (only if there are labels to add) if (labels.length > 0) { - github.rest.issues.addLabels({ + await github.rest.issues.addLabels({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, @@ -128,3 +129,13 @@ jobs: } env: TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }} + + # Run after labeling so fallback API failures do not prevent labels. + # Use the app token so assigning a type can trigger the triage workflow. + - name: Set missing issue type + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ steps.github-auth.outputs.token }} + script: | + const setMissingIssueType = require('./.github/scripts/set_missing_issue_type.js'); + await setMissingIssueType({ github, context, core }); From aea4dc221e977798bf9913d6dbe8cb10148bda12 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Tue, 8 Sep 2026 09:31:55 +0000 Subject: [PATCH 81/85] Python: Preserve Param unset sentinel identity (#8145) Avoid copying the unset sentinel during Param construction and default access while preserving defensive copies of supplied defaults. Add compatibility and nested mutable-default regressions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../core/agent_framework/_vector_filters.py | 6 +- .../packages/core/tests/core/test_vectors.py | 61 +++++++++++++++++-- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/python/packages/core/agent_framework/_vector_filters.py b/python/packages/core/agent_framework/_vector_filters.py index c4488c715c1..3f410e2ebc1 100644 --- a/python/packages/core/agent_framework/_vector_filters.py +++ b/python/packages/core/agent_framework/_vector_filters.py @@ -255,7 +255,7 @@ def __init__( object.__setattr__(self, "name", name) object.__setattr__(self, "value_type", value_type) object.__setattr__(self, "required", required) - object.__setattr__(self, "_default", deepcopy(default)) + object.__setattr__(self, "_default", default if default is _PARAM_UNSET else deepcopy(default)) object.__setattr__(self, "omit_if_none", omit_if_none) object.__setattr__(self, "description", description) object.__setattr__(self, "minimum", minimum) @@ -271,8 +271,8 @@ def has_default(self) -> bool: @property def default(self) -> Any: - """Return an independent copy of the default value.""" - return deepcopy(self._default) + """Return an independent copy of the default value, or the unset sentinel.""" + return deepcopy(self._default) if self.has_default else _PARAM_UNSET def __deepcopy__(self, memo: dict[int, Any]) -> Param: return self diff --git a/python/packages/core/tests/core/test_vectors.py b/python/packages/core/tests/core/test_vectors.py index ac56df4e048..0378804b180 100644 --- a/python/packages/core/tests/core/test_vectors.py +++ b/python/packages/core/tests/core/test_vectors.py @@ -4,6 +4,7 @@ import warnings from collections.abc import AsyncIterable, Callable, Iterator, Mapping, Sequence +from copy import deepcopy from dataclasses import FrozenInstanceError, dataclass, field from decimal import Decimal from typing import Annotated, Any, ClassVar, Literal, cast @@ -45,7 +46,7 @@ ) from agent_framework._feature_stage import ExperimentalWarning from agent_framework._telemetry import FeatureIndex -from agent_framework._vector_filters import filter_values_equal +from agent_framework._vector_filters import _PARAM_UNSET, filter_values_equal from agent_framework._vectors import _VectorStoreRecordHandler as VectorStoreRecordHandler from agent_framework.exceptions import IntegrationException, IntegrationInvalidResponseException @@ -1833,6 +1834,37 @@ def test_filter_model_rejects_invalid_shapes() -> None: Filter("text", "in", ("fixed", Param("dynamic", str))) +@pytest.mark.parametrize("required", [False, True]) +async def test_param_without_default_preserves_unset_sentinel(required: bool) -> None: + def copy_value(value: Any) -> Any: + if value is _PARAM_UNSET: + raise TypeError("Cannot pickle 'Sentinel' object") + return deepcopy(value) + + with patch("agent_framework._vector_filters.deepcopy", side_effect=copy_value): + param = Param("tenant", str, required=required) + assert not param.has_default + assert param.default is _PARAM_UNSET + assert deepcopy(param) is param + expression = Filter("text", "eq", param) + assert deepcopy(expression).value is param + + collection = MockCollection() + tool = create_vector_search_tool(collection, filter=expression) + schema = tool.parameters() + assert schema["properties"]["tenant"] == {"type": "string"} + assert schema["required"] == (["query", "tenant"] if required else ["query"]) + + await tool(query="query", tenant="acme") + assert collection.last_search_filter == Filter("text", "eq", "acme") + if required: + with pytest.raises(TypeError, match="Missing required.*tenant"): + await tool(query="query") + else: + await tool(query="query") + assert collection.last_search_filter is None + + def test_param_generates_native_schema_and_validates_constraints() -> None: param = Param( "category", @@ -2332,6 +2364,8 @@ async def test_search_tool_null_omission_is_opt_in_per_parameter() -> None: ) assert not retained.omit_if_none + assert retained.has_default + assert retained.default is None await tool(query="query", text=None, native=None) assert collection.last_search_filter == FilterGroup("and", (Filter("text", "provider.nullable", None),)) await tool(query="query") @@ -2429,24 +2463,39 @@ async def search( assert supplied == ["acme"] -async def test_search_tool_deep_copies_supplied_mapping_values() -> None: +@pytest.mark.parametrize("supply_argument", [False, True]) +async def test_search_tool_deep_copies_mapping_values(supply_argument: bool) -> None: class MutatingSearch: async def search( self, values: Any, *, filter: Filter | FilterGroup | None = None, **kwargs: Any ) -> SearchResults[SearchResponse[Record]]: assert isinstance(filter, Filter) + assert filter.value == {"tags": ["supplied" if supply_argument else "original"]} filter.value["tags"].append("mutated") return SearchResults([]) + source_default = {"tags": ["original"]} + param = Param("metadata", dict[str, list[str]], default=source_default) + source_default["tags"].append("source mutation") + returned_default = param.default + returned_default["tags"].append("access mutation") + assert param.has_default + assert param.default == {"tags": ["original"]} + assert deepcopy(param) is param + tool = create_vector_search_tool( cast(SupportsVectorSearch[Record], MutatingSearch()), - filter=Filter("metadata", "provider.native", Param("metadata", dict[str, list[str]])), + filter=Filter("metadata", "provider.native", param), ) - supplied = {"tags": ["original"]} + tool.parameters()["properties"]["metadata"]["default"]["tags"].append("schema mutation") + supplied = {"tags": ["supplied"]} + arguments = {"metadata": supplied} if supply_argument else {} - await tool(query="query", metadata=supplied) + await tool(query="first", **arguments) + await tool(query="second", **arguments) - assert supplied == {"tags": ["original"]} + assert supplied == {"tags": ["supplied"]} + assert param.default == {"tags": ["original"]} async def test_runtime_operations_mark_vector_store_feature_usage() -> None: From 59e0fcfc1116ea1079b10ea7b461135c5f3b5b47 Mon Sep 17 00:00:00 2001 From: Dineshsuriya D <43177361+droideronline@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:52:44 +0000 Subject: [PATCH 82/85] Python: support mixed workflow invocation kwargs (#7963) * Python: support mixed workflow invocation kwargs * Python: preserve workflow kwargs compatibility * Python: fix workflow kwargs type checking * Fix workflow cancellation and response routing * Fix nested workflow request cancellation --- .../packages/core/agent_framework/__init__.py | 3 +- .../core/agent_framework/__init__.pyi | 3 +- .../core/agent_framework/_workflows/_agent.py | 50 ++++--- .../_workflows/_agent_executor.py | 26 ++-- .../core/agent_framework/_workflows/_const.py | 4 + .../agent_framework/_workflows/_workflow.py | 123 +++++++++++------- .../_workflows/_workflow_executor.py | 53 +++++--- .../tests/workflow/test_agent_executor.py | 33 +---- .../tests/workflow/test_workflow_kwargs.py | 88 +++++++++++++ 9 files changed, 263 insertions(+), 120 deletions(-) diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index c80b5025eb0..691c35e9110 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -374,7 +374,7 @@ "validate_workflow_graph", ), "._workflows._viz": ("WorkflowViz",), - "._workflows._workflow": ("Workflow", "WorkflowRunResult"), + "._workflows._workflow": ("Workflow", "WorkflowInvocationKwargs", "WorkflowRunResult"), "._workflows._workflow_builder": ("WorkflowBuilder",), "._workflows._workflow_context": ("WorkflowContext",), "._workflows._workflow_executor": ( @@ -646,6 +646,7 @@ "WorkflowEventType", "WorkflowException", "WorkflowExecutor", + "WorkflowInvocationKwargs", "WorkflowMessage", "WorkflowRunResult", "WorkflowRunState", diff --git a/python/packages/core/agent_framework/__init__.pyi b/python/packages/core/agent_framework/__init__.pyi index a18848f56f3..b303e0d98e7 100644 --- a/python/packages/core/agent_framework/__init__.pyi +++ b/python/packages/core/agent_framework/__init__.pyi @@ -328,7 +328,7 @@ from ._workflows._validation import ( validate_workflow_graph, ) from ._workflows._viz import WorkflowViz -from ._workflows._workflow import Workflow, WorkflowRunResult +from ._workflows._workflow import Workflow, WorkflowInvocationKwargs, WorkflowRunResult from ._workflows._workflow_builder import WorkflowBuilder from ._workflows._workflow_context import WorkflowContext from ._workflows._workflow_executor import SubWorkflowRequestMessage, SubWorkflowResponseMessage, WorkflowExecutor @@ -603,6 +603,7 @@ __all__ = [ "WorkflowExecutor", "WorkflowMessage", "WorkflowRunResult", + "WorkflowInvocationKwargs", "WorkflowRunState", "WorkflowRunnerException", "WorkflowValidationError", diff --git a/python/packages/core/agent_framework/_workflows/_agent.py b/python/packages/core/agent_framework/_workflows/_agent.py index 0df47d2b345..8198ad297e7 100644 --- a/python/packages/core/agent_framework/_workflows/_agent.py +++ b/python/packages/core/agent_framework/_workflows/_agent.py @@ -45,7 +45,7 @@ from typing_extensions import TypedDict # pragma: no cover if TYPE_CHECKING: - from ._workflow import Workflow + from ._workflow import Workflow, WorkflowInvocationKwargs logger = logging.getLogger(__name__) @@ -157,9 +157,12 @@ def run( checkpoint_id: str | None = None, checkpoint_storage: CheckpointStorage | None = None, tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - function_invocation_kwargs: Mapping[str, Any] | None = None, - client_kwargs: Mapping[str, Any] | None = None, - ) -> Awaitable[AgentResponse[Any]]: ... + function_invocation_kwargs: WorkflowInvocationKwargs + | Mapping[str, Mapping[str, Any]] + | Mapping[str, Any] + | None = None, + client_kwargs: WorkflowInvocationKwargs | Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: ... @overload def run( @@ -171,9 +174,12 @@ def run( checkpoint_id: str | None = None, checkpoint_storage: CheckpointStorage | None = None, tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - function_invocation_kwargs: Mapping[str, Any] | None = None, - client_kwargs: Mapping[str, Any] | None = None, - ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... + function_invocation_kwargs: WorkflowInvocationKwargs + | Mapping[str, Mapping[str, Any]] + | Mapping[str, Any] + | None = None, + client_kwargs: WorkflowInvocationKwargs | Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: ... def run( self, @@ -184,9 +190,12 @@ def run( checkpoint_id: str | None = None, checkpoint_storage: CheckpointStorage | None = None, tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - function_invocation_kwargs: Mapping[str, Any] | None = None, - client_kwargs: Mapping[str, Any] | None = None, - ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]] | Awaitable[AgentResponse[Any]]: + function_invocation_kwargs: WorkflowInvocationKwargs + | Mapping[str, Mapping[str, Any]] + | Mapping[str, Any] + | None = None, + client_kwargs: WorkflowInvocationKwargs | Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse] | Awaitable[AgentResponse]: """Get a response from the workflow agent. Args: @@ -254,8 +263,11 @@ async def _run_impl( checkpoint_id: str | None = None, checkpoint_storage: CheckpointStorage | None = None, tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - function_invocation_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, - client_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, + function_invocation_kwargs: WorkflowInvocationKwargs + | Mapping[str, Mapping[str, Any]] + | Mapping[str, Any] + | None = None, + client_kwargs: WorkflowInvocationKwargs | Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, ) -> AgentResponse: """Internal implementation of non-streaming execution. @@ -337,8 +349,11 @@ async def _run_stream_impl( checkpoint_id: str | None = None, checkpoint_storage: CheckpointStorage | None = None, tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - function_invocation_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, - client_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, + function_invocation_kwargs: WorkflowInvocationKwargs + | Mapping[str, Mapping[str, Any]] + | Mapping[str, Any] + | None = None, + client_kwargs: WorkflowInvocationKwargs | Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, ) -> AsyncIterable[AgentResponseUpdate]: """Internal implementation of streaming execution. @@ -419,8 +434,11 @@ async def _run_core( checkpoint_storage: CheckpointStorage | None, streaming: bool, tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - function_invocation_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, - client_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, + function_invocation_kwargs: WorkflowInvocationKwargs + | Mapping[str, Mapping[str, Any]] + | Mapping[str, Any] + | None = None, + client_kwargs: WorkflowInvocationKwargs | Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, ) -> AsyncIterable[WorkflowEvent]: """Core implementation that yields workflow events for both streaming and non-streaming modes. diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py index 512706e36f4..1c3a7203f49 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_executor.py +++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py @@ -614,22 +614,26 @@ def _resolve_executor_kwargs(self, resolved: dict[str, Any] | None) -> dict[str, """ if not isinstance(resolved, dict): return None - # Use explicit key-presence checks so that an empty per-executor dict is - # honoured (e.g. to clear kwargs) instead of falling through to global. - if self.id in resolved: - executor_kwargs = resolved[self.id] - elif GLOBAL_KWARGS_KEY in resolved: - executor_kwargs = resolved[GLOBAL_KWARGS_KEY] - else: + global_kwargs: Any = resolved.get(GLOBAL_KWARGS_KEY) + executor_kwargs: Any = resolved.get(self.id) + if global_kwargs is None and executor_kwargs is None: return None - if not isinstance(executor_kwargs, dict): + if global_kwargs is not None and not isinstance(global_kwargs, dict): logger.warning( - "Executor %s expected a dict for its kwargs, but got %s. Ignoring.", + "Executor %s expected a dict for global kwargs, but got %s. Ignoring.", self.id, - type(executor_kwargs), # type: ignore + cast(type[Any], type(global_kwargs)), ) + return None + if executor_kwargs is not None and not isinstance(executor_kwargs, dict): + logger.warning( + "Executor %s expected a dict for its kwargs, but got %s. Ignoring.", + self.id, + cast(type[Any], type(executor_kwargs)), + ) return None - return executor_kwargs # type: ignore + # Specific values override global values for the same function argument. + return {**(global_kwargs or {}), **(executor_kwargs or {})} diff --git a/python/packages/core/agent_framework/_workflows/_const.py b/python/packages/core/agent_framework/_workflows/_const.py index 27b9c249617..f01b45d8407 100644 --- a/python/packages/core/agent_framework/_workflows/_const.py +++ b/python/packages/core/agent_framework/_workflows/_const.py @@ -17,6 +17,10 @@ # to pass kwargs from workflow.run() through to agent.run() and @tool functions. WORKFLOW_RUN_KWARGS_KEY = "_workflow_run_kwargs" +# State keys used to preserve caller-provided kwargs for nested workflow routing. +RAW_FUNCTION_INVOCATION_KWARGS_KEY = "_raw_function_invocation_kwargs" +RAW_CLIENT_KWARGS_KEY = "_raw_client_kwargs" + # Sentinel key used in resolved invocation kwargs dicts to denote global kwargs # that apply to all executors (as opposed to per-executor keyed entries). GLOBAL_KWARGS_KEY = "__global__" diff --git a/python/packages/core/agent_framework/_workflows/_workflow.py b/python/packages/core/agent_framework/_workflows/_workflow.py index ab89df1e5b3..6e9f256b9bd 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow.py +++ b/python/packages/core/agent_framework/_workflows/_workflow.py @@ -21,7 +21,14 @@ from ..exceptions import WorkflowException from ..observability import OtelAttr, capture_exception, create_workflow_span from ._checkpoint import CheckpointStorage -from ._const import DEFAULT_MAX_ITERATIONS, GLOBAL_KWARGS_KEY, INTERNAL_SOURCE_ID, WORKFLOW_RUN_KWARGS_KEY +from ._const import ( + DEFAULT_MAX_ITERATIONS, + GLOBAL_KWARGS_KEY, + INTERNAL_SOURCE_ID, + RAW_CLIENT_KWARGS_KEY, + RAW_FUNCTION_INVOCATION_KWARGS_KEY, + WORKFLOW_RUN_KWARGS_KEY, +) from ._edge import ( EdgeGroup, FanOutEdgeGroup, @@ -206,6 +213,20 @@ def classify(self, executor_id: str) -> Literal["output", "intermediate"] | None return None +@dataclass(frozen=True) +class WorkflowInvocationKwargs: + """Explicit global and executor-specific kwargs for a workflow run. + + Use this wrapper when shared kwargs should be combined with executor-specific + overrides. Plain mappings retain their existing global or per-executor behavior. + """ + + global_kwargs: Mapping[str, Any] = field(default_factory=lambda: dict[str, Any]()) + executor_kwargs: Mapping[str, Mapping[str, Any]] = field( + default_factory=lambda: dict[str, Mapping[str, Any]]() + ) + + class Workflow(DictConvertible): """A graph-based execution engine that orchestrates connected executors. @@ -482,8 +503,11 @@ async def _run_workflow_with_tracing( is_continuation: bool = False, streaming: bool = False, tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - function_invocation_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, - client_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, + function_invocation_kwargs: WorkflowInvocationKwargs + | Mapping[str, Mapping[str, Any]] + | Mapping[str, Any] + | None = None, + client_kwargs: WorkflowInvocationKwargs | Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, ) -> AsyncIterable[WorkflowEvent]: """Private method to run workflow with proper tracing. @@ -558,10 +582,18 @@ async def _run_workflow_with_tracing( combined_kwargs["function_invocation_kwargs"] = self._resolve_invocation_kwargs( function_invocation_kwargs, "function_invocation_kwargs" ) + if isinstance(function_invocation_kwargs, WorkflowInvocationKwargs) or any( + isinstance(value, Mapping) for value in function_invocation_kwargs.values() + ): + combined_kwargs[RAW_FUNCTION_INVOCATION_KWARGS_KEY] = function_invocation_kwargs if client_kwargs is not None: combined_kwargs["client_kwargs"] = self._resolve_invocation_kwargs( client_kwargs, "client_kwargs" ) + if isinstance(client_kwargs, WorkflowInvocationKwargs) or any( + isinstance(value, Mapping) for value in client_kwargs.values() + ): + combined_kwargs[RAW_CLIENT_KWARGS_KEY] = client_kwargs self._runner.state.set(WORKFLOW_RUN_KWARGS_KEY, combined_kwargs) elif not is_continuation: self._runner.state.set(WORKFLOW_RUN_KWARGS_KEY, {}) @@ -691,8 +723,14 @@ def run( checkpoint_id: str | None = None, checkpoint_storage: CheckpointStorage | None = None, tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - function_invocation_kwargs: Mapping[str, Any] | None = None, - client_kwargs: Mapping[str, Any] | None = None, + function_invocation_kwargs: WorkflowInvocationKwargs + | Mapping[str, Mapping[str, Any]] + | Mapping[str, Any] + | None = None, + client_kwargs: WorkflowInvocationKwargs + | Mapping[str, Mapping[str, Any]] + | Mapping[str, Any] + | None = None, ) -> ResponseStream[WorkflowEvent, WorkflowRunResult]: ... @overload @@ -706,8 +744,8 @@ def run( checkpoint_storage: CheckpointStorage | None = None, include_status_events: bool = False, tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - function_invocation_kwargs: Mapping[str, Any] | None = None, - client_kwargs: Mapping[str, Any] | None = None, + function_invocation_kwargs: WorkflowInvocationKwargs | Mapping[str, Any] | None = None, + client_kwargs: WorkflowInvocationKwargs | Mapping[str, Any] | None = None, ) -> Awaitable[WorkflowRunResult]: ... def run( @@ -720,8 +758,11 @@ def run( checkpoint_storage: CheckpointStorage | None = None, include_status_events: bool = False, tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - function_invocation_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, - client_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, + function_invocation_kwargs: WorkflowInvocationKwargs + | Mapping[str, Mapping[str, Any]] + | Mapping[str, Any] + | None = None, + client_kwargs: WorkflowInvocationKwargs | Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, ) -> ResponseStream[WorkflowEvent, WorkflowRunResult] | Awaitable[WorkflowRunResult]: """Run the workflow, optionally streaming events. @@ -746,10 +787,14 @@ def run( tools: Runtime tools available to agent executors. function_invocation_kwargs: Keyword arguments forwarded to tool invocations in subagents. Either a mapping for agent name or agent executor id to kwargs, - or a flat mapping of kwargs for all tool invocations. + a flat mapping of kwargs for all tool invocations, or a + ``WorkflowInvocationKwargs`` instance to combine global and executor-specific + kwargs. client_kwargs: Keyword arguments forwarded to chat client calls in subagents. Either a mapping for agent name or agent executor id to kwargs, - or a flat mapping of kwargs for all chat client calls. + a flat mapping of kwargs for all chat client calls, or a + ``WorkflowInvocationKwargs`` instance to combine global and executor-specific + kwargs. Returns: When stream=True: A ResponseStream[WorkflowEvent, WorkflowRunResult] for @@ -791,7 +836,7 @@ def run( checkpoint_id=checkpoint_id, checkpoint_storage=checkpoint_storage, streaming=stream, - tools=runtime_tools, + tools=tools, function_invocation_kwargs=function_invocation_kwargs, client_kwargs=client_kwargs, ), @@ -812,8 +857,11 @@ async def _run_core( checkpoint_storage: CheckpointStorage | None = None, streaming: bool = False, tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - function_invocation_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, - client_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, + function_invocation_kwargs: WorkflowInvocationKwargs + | Mapping[str, Mapping[str, Any]] + | Mapping[str, Any] + | None = None, + client_kwargs: WorkflowInvocationKwargs | Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, ) -> AsyncIterable[WorkflowEvent]: """Single core execution path for both streaming and non-streaming modes. @@ -1079,7 +1127,7 @@ def _get_executor_by_id(self, executor_id: str) -> Executor: def _resolve_invocation_kwargs( self, - kwargs: Mapping[str, Any], + kwargs: WorkflowInvocationKwargs | Mapping[str, Any], param_name: str, ) -> dict[str, Any]: """Resolve invocation kwargs into a normalized per-executor or global format. @@ -1087,17 +1135,24 @@ def _resolve_invocation_kwargs( Detects whether the provided kwargs dict uses per-executor targeting by checking if any top-level key matches a known executor ID in the workflow. If at least one key matches, all entries are treated as per-executor. Otherwise the dict is treated - as global kwargs that apply to every executor. + as global kwargs that apply to every executor. The ``"__global__"`` key can be used + explicitly to combine global kwargs with per-executor overrides. Args: kwargs: The raw invocation kwargs from the caller. param_name: The parameter name (for logging), e.g. ``"function_invocation_kwargs"``. Returns: - A dict with either: - - ``{"__global__": }`` for global kwargs, or - - The original dict unchanged for per-executor kwargs. + A dict containing normalized global or per-executor mappings. """ + if isinstance(kwargs, WorkflowInvocationKwargs): + resolved = {GLOBAL_KWARGS_KEY: dict(kwargs.global_kwargs)} + resolved.update({ + executor_id: dict(executor_kwargs) for executor_id, executor_kwargs in kwargs.executor_kwargs.items() + }) + logger.info("Explicit global %s provided with executor-specific overrides.", param_name) + return resolved + executor_ids = set(self.executors.keys()) matched_ids = kwargs.keys() & executor_ids if matched_ids: @@ -1221,29 +1276,10 @@ async def cancel_pending_requests( checkpoint_id: str | None = None, checkpoint_storage: CheckpointStorage | None = None, tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - function_invocation_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, - client_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, + function_invocation_kwargs: WorkflowInvocationKwargs | Mapping[str, Any] | None = None, + client_kwargs: WorkflowInvocationKwargs | Mapping[str, Any] | None = None, ) -> WorkflowRunResult: - """Cancel pending external requests and release their owning executor state. - - Cancellation follows requests through nested workflows and clears any executor-owned - correlation without synthesizing a response. If cancellation drains an executor's pending - set after sibling responses were already accepted, the executor resumes through its normal - continuation path. Unknown or already-handled request IDs are ignored. - - Args: - request_ids: Request identifiers to cancel. - - Keyword Args: - checkpoint_id: Checkpoint to restore before applying cancellation. - checkpoint_storage: Runtime checkpoint storage for the cancellation continuation. - tools: Request-scoped tools available while cancellation resumes executors. - function_invocation_kwargs: Keyword arguments forwarded to resumed tool invocations. - client_kwargs: Keyword arguments forwarded to resumed chat client calls. - - Returns: - Events produced while applying cancellation and any resulting continuation. - """ + """Cancel pending external requests and continue the workflow.""" selected_ids = set(request_ids) if not all(isinstance(request_id, str) and request_id for request_id in selected_ids): raise ValueError("Pending workflow request IDs must be non-empty strings.") @@ -1260,10 +1296,7 @@ async def apply_cancellations() -> None: state=self._runner.state, runner_context=self._runner.context, ) - await executor._cancel_pending_request( # pyright: ignore[reportPrivateUsage] - request_id, - context, - ) + await executor._cancel_pending_request(request_id, context) # pyright: ignore[reportPrivateUsage] if checkpoint_storage is not None: self._runner.context.set_runtime_checkpoint_storage(checkpoint_storage) diff --git a/python/packages/core/agent_framework/_workflows/_workflow_executor.py b/python/packages/core/agent_framework/_workflows/_workflow_executor.py index fce77a540c8..749283d2d74 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_executor.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_executor.py @@ -3,13 +3,19 @@ import logging import sys import types +from collections.abc import Mapping from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast if TYPE_CHECKING: from ._workflow import Workflow -from ._const import GLOBAL_KWARGS_KEY, WORKFLOW_RUN_KWARGS_KEY +from ._const import ( + GLOBAL_KWARGS_KEY, + RAW_CLIENT_KWARGS_KEY, + RAW_FUNCTION_INVOCATION_KWARGS_KEY, + WORKFLOW_RUN_KWARGS_KEY, +) from ._edge_runner import gather_cancelling_siblings_on_error from ._events import ( WorkflowEvent, @@ -20,7 +26,7 @@ from ._request_info_mixin import response_handler from ._runner_context import WorkflowMessage from ._typing_utils import is_instance_of -from ._workflow import WorkflowRunResult +from ._workflow import WorkflowInvocationKwargs, WorkflowRunResult from ._workflow_context import WorkflowContext if sys.version_info >= (3, 12): @@ -375,29 +381,36 @@ async def process_workflow(self, input_data: object, ctx: WorkflowContext[Any, A # Get kwargs from parent workflow's State to propagate to subworkflow parent_kwargs: dict[str, Any] = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {}) - # Extract invocation kwargs recognised by Workflow.run() - # The state stores resolved format (with __global__ wrapper for global kwargs). - # Unwrap __global__ before passing to the subworkflow so it gets re-resolved - # against the subworkflow's own executor IDs. - fi_kwargs: dict[str, Any] | None = None - ci_kwargs: dict[str, Any] | None = None - tools = ctx.get_runtime_tools() + # Use the caller's raw kwargs so legacy per-executor mappings are resolved + # against the child workflow's executor IDs rather than the parent's. + fi_kwargs: WorkflowInvocationKwargs | Mapping[str, Any] | None = None + ci_kwargs: WorkflowInvocationKwargs | Mapping[str, Any] | None = None for key in ("function_invocation_kwargs", "client_kwargs"): - resolved = parent_kwargs.get(key) - if isinstance(resolved, dict): - # Unwrap global sentinel; pass per-executor dicts as-is - unwrapped: dict[str, Any] = resolved.get(GLOBAL_KWARGS_KEY, resolved) # type: ignore + raw_key = ( + RAW_FUNCTION_INVOCATION_KWARGS_KEY if key == "function_invocation_kwargs" else RAW_CLIENT_KWARGS_KEY + ) + raw_value = parent_kwargs.get(raw_key) + if raw_value is not None: + resolved = cast(WorkflowInvocationKwargs | Mapping[str, Any], raw_value) + else: + normalized: Any = parent_kwargs.get(key) + if isinstance(normalized, dict): + normalized_dict = cast(dict[str, Any], normalized) + if len(normalized_dict) == 1 and GLOBAL_KWARGS_KEY in normalized_dict: + normalized = normalized_dict[GLOBAL_KWARGS_KEY] + resolved = cast(WorkflowInvocationKwargs | Mapping[str, Any] | None, normalized) + if resolved is not None: if key == "function_invocation_kwargs": - fi_kwargs = unwrapped # type: ignore + fi_kwargs = resolved else: - ci_kwargs = unwrapped # type: ignore + ci_kwargs = resolved # Run the sub-workflow and collect all events, passing parent kwargs result = await self.workflow.run( input_data, - tools=tools, - function_invocation_kwargs=fi_kwargs, # type: ignore - client_kwargs=ci_kwargs, # type: ignore + tools=ctx.get_runtime_tools(), + function_invocation_kwargs=fi_kwargs, + client_kwargs=ci_kwargs, ) logger.debug(f"WorkflowExecutor {self.id} sub-workflow {self.workflow.id} completed with {len(result)} events") @@ -621,5 +634,5 @@ async def _handle_response( # Forward the response to the sub-workflow, which resumes and validates it against its own # pending requests, then process whatever the sub-workflow produces. - result = await self.workflow.run(responses={request_id: response}, tools=ctx.get_runtime_tools()) + result = await self.workflow.run(responses={request_id: response}) await self._process_workflow_result(result, ctx) diff --git a/python/packages/core/tests/workflow/test_agent_executor.py b/python/packages/core/tests/workflow/test_agent_executor.py index 2cc2ed2ce6e..9d7e4978a06 100644 --- a/python/packages/core/tests/workflow/test_agent_executor.py +++ b/python/packages/core/tests/workflow/test_agent_executor.py @@ -1,7 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. import pickle - from collections.abc import AsyncIterable, Awaitable from typing import Any, Literal, overload @@ -338,9 +337,6 @@ class _NonCopyableRaw: def __deepcopy__(self, memo: dict) -> Any: raise TypeError("Cannot deepcopy this object") - def __reduce__(self) -> Any: - raise TypeError("Cannot pickle this object") - class _AgentWithRawRepr(BaseAgent): """Agent that returns responses with a non-copyable raw_representation.""" @@ -392,20 +388,6 @@ async def test_agent_executor_workflow_with_non_copyable_raw_representation() -> assert agent_responses[0].raw_representation is raw -def test_serialization_mixin_omits_non_pickleable_raw_representation() -> None: - """Pickling framework objects should not include runtime-only raw representations.""" - raw = _NonCopyableRaw() - response = AgentResponse( - messages=[Message("assistant", [Content.from_text(text="reply", raw_representation=raw)])], - raw_representation=raw, - ) - - restored = pickle.loads(pickle.dumps(response)) - - assert restored.raw_representation is None - assert restored.messages[0].contents[0].raw_representation is None - - # --------------------------------------------------------------------------- # Context mode tests # --------------------------------------------------------------------------- @@ -641,15 +623,15 @@ async def test_resolve_executor_kwargs_returns_none_for_none_input() -> None: assert result is None -async def test_resolve_executor_kwargs_prefers_executor_id_over_global() -> None: - """_resolve_executor_kwargs prefers executor-specific entry over __global__.""" +async def test_resolve_executor_kwargs_merges_executor_id_over_global() -> None: + """_resolve_executor_kwargs merges executor-specific entries over __global__.""" agent = _CountingAgent(id="a", name="A") executor = AgentExecutor(agent, id="exec_a") # Dict has both a per-executor entry and a global entry resolved = {"exec_a": {"specific": True}, GLOBAL_KWARGS_KEY: {"global": True}} result = executor._resolve_executor_kwargs(resolved) # pyright: ignore[reportPrivateUsage] - assert result == {"specific": True} + assert result == {"global": True, "specific": True} async def test_prepare_agent_run_args_extracts_function_invocation_kwargs() -> None: @@ -708,16 +690,15 @@ async def test_prepare_agent_run_args_per_executor_no_match() -> None: assert fi_kwargs is None -async def test_resolve_executor_kwargs_empty_per_executor_does_not_fallback_to_global() -> None: - """An explicit empty per-executor dict should not fall through to global kwargs.""" +async def test_resolve_executor_kwargs_empty_per_executor_keeps_global_kwargs() -> None: + """An explicit empty per-executor dict keeps the global kwargs.""" agent = _CountingAgent(id="a", name="A") executor = AgentExecutor(agent, id="exec_a") - # Per-executor entry for exec_a is empty, but global has values. - # The empty dict should be honoured (no fallback to global). + # Per-executor entry for exec_a is empty, so only global values apply. resolved = {"exec_a": {}, GLOBAL_KWARGS_KEY: {"global_key": "global_val"}} # type: ignore[var-annotated] result = executor._resolve_executor_kwargs(resolved) # pyright: ignore[reportPrivateUsage] - assert result == {} + assert result == {"global_key": "global_val"} # region Tool approval emission diff --git a/python/packages/core/tests/workflow/test_workflow_kwargs.py b/python/packages/core/tests/workflow/test_workflow_kwargs.py index c21a25a5d7a..d91d224c54f 100644 --- a/python/packages/core/tests/workflow/test_workflow_kwargs.py +++ b/python/packages/core/tests/workflow/test_workflow_kwargs.py @@ -17,6 +17,7 @@ FunctionTool, Message, ResponseStream, + WorkflowInvocationKwargs, WorkflowRunState, ) from agent_framework._workflows._const import WORKFLOW_RUN_KWARGS_KEY @@ -1070,6 +1071,35 @@ async def test_nested_subworkflow_kwargs_propagation() -> None: ) +async def test_mixed_kwargs_route_through_subworkflow() -> None: + """Mixed kwargs preserve global values and child executor-specific routing.""" + from agent_framework._workflows._workflow_executor import WorkflowExecutor + + inner_agent1 = _KwargsCapturingAgent(name="inner_agent1") + inner_agent2 = _KwargsCapturingAgent(name="inner_agent2") + inner_workflow = SequentialBuilder(participants=[inner_agent1, inner_agent2]).build() + subworkflow_executor = WorkflowExecutor(workflow=inner_workflow, id="subworkflow") + outer_workflow = SequentialBuilder(participants=[subworkflow_executor]).build() + + fi_kwargs = WorkflowInvocationKwargs( + global_kwargs={"shared": "value", "overridden": "global"}, + executor_kwargs={"inner_agent2": {"overridden": "inner_agent2"}}, + ) + + async for event in outer_workflow.run("test", stream=True, function_invocation_kwargs=fi_kwargs): + if event.type == "status" and event.state == WorkflowRunState.IDLE: + break + + assert inner_agent1.captured_kwargs[0].get("function_invocation_kwargs") == { + "shared": "value", + "overridden": "global", + } + assert inner_agent2.captured_kwargs[0].get("function_invocation_kwargs") == { + "shared": "value", + "overridden": "inner_agent2", + } + + # endregion @@ -1151,6 +1181,35 @@ async def test_per_executor_function_invocation_kwargs_routes_to_correct_agent() assert agent2.captured_kwargs[0].get("function_invocation_kwargs") == {"tool_param": "value_for_agent2"} +async def test_global_and_per_executor_function_invocation_kwargs_are_merged() -> None: + """Global function kwargs are merged with executor-specific overrides.""" + agent1 = _KwargsCapturingAgent(name="agent1") + agent2 = _KwargsCapturingAgent(name="agent2") + workflow = SequentialBuilder(participants=[agent1, agent2]).build() + + fi_kwargs = WorkflowInvocationKwargs( + global_kwargs={"shared": "value", "overridden": "global"}, + executor_kwargs={ + "agent1": {"overridden": "agent1"}, + "agent2": {"agent_only": True}, + }, + ) + + async for event in workflow.run("test", stream=True, function_invocation_kwargs=fi_kwargs): + if event.type == "status" and event.state == WorkflowRunState.IDLE: + break + + assert agent1.captured_kwargs[0].get("function_invocation_kwargs") == { + "shared": "value", + "overridden": "agent1", + } + assert agent2.captured_kwargs[0].get("function_invocation_kwargs") == { + "shared": "value", + "overridden": "global", + "agent_only": True, + } + + async def test_per_executor_kwargs_unmatched_agent_gets_none() -> None: """An agent not targeted in per-executor kwargs should receive None for that kwarg.""" agent1 = _KwargsCapturingAgent(name="agent1") @@ -1221,6 +1280,35 @@ async def test_per_executor_client_kwargs_routes_correctly() -> None: assert agent2.captured_kwargs[0].get("client_kwargs") == {"temperature": 0.9} +async def test_global_and_per_executor_client_kwargs_are_merged() -> None: + """Global client kwargs are merged with executor-specific overrides.""" + agent1 = _KwargsCapturingAgent(name="agent1") + agent2 = _KwargsCapturingAgent(name="agent2") + workflow = SequentialBuilder(participants=[agent1, agent2]).build() + + ci_kwargs = WorkflowInvocationKwargs( + global_kwargs={"shared": "value", "overridden": "global"}, + executor_kwargs={ + "agent1": {"overridden": "agent1"}, + "agent2": {"agent_only": True}, + }, + ) + + async for event in workflow.run("test", stream=True, client_kwargs=ci_kwargs): + if event.type == "status" and event.state == WorkflowRunState.IDLE: + break + + assert agent1.captured_kwargs[0].get("client_kwargs") == { + "shared": "value", + "overridden": "agent1", + } + assert agent2.captured_kwargs[0].get("client_kwargs") == { + "shared": "value", + "overridden": "global", + "agent_only": True, + } + + async def test_resolve_invocation_kwargs_logs_per_executor(caplog: "LogCaptureFixture") -> None: """Workflow._resolve_invocation_kwargs logs info when per-executor format is detected.""" import logging From 25959b71c137c95262d63f64817d513dce6c250e Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Tue, 8 Sep 2026 10:31:09 +0000 Subject: [PATCH 83/85] Python: preserve MCP Host payload metadata (#8128) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- python/packages/core/agent_framework/_mcp.py | 358 ++++++++- .../packages/core/agent_framework/_tools.py | 173 ++++- python/packages/core/tests/core/test_mcp.py | 691 +++++++++++++++++- 3 files changed, 1166 insertions(+), 56 deletions(-) diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index f1bf4ea4bc0..c652b42d216 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -14,14 +14,16 @@ from abc import abstractmethod from collections.abc import Callable, Collection, Coroutine, Mapping, Sequence from contextlib import AsyncExitStack, _AsyncGeneratorContextManager # type: ignore +from copy import copy from dataclasses import dataclass -from datetime import timedelta +from datetime import date, datetime, timedelta from functools import partial from inspect import isawaitable from typing import TYPE_CHECKING, Any, Literal, TypeAlias, TypedDict, cast from opentelemetry import propagate from opentelemetry import trace as otel_trace +from pydantic_core import to_jsonable_python from ._feature_stage import ( ExperimentalFeature, @@ -29,8 +31,15 @@ _warn_on_feature_use, # pyright: ignore[reportPrivateUsage] experimental, ) +from ._serialization import make_json_safe from ._telemetry import FeatureIndex, mark_feature_used -from ._tools import FunctionTool +from ._tools import ( + _FUNCTION_RESULT_CARRIER_CONTEXT_KEY, # pyright: ignore[reportPrivateUsage] + _FUNCTION_RESULT_PAYLOAD_BUDGET_CONTEXT_KEY, # pyright: ignore[reportPrivateUsage] + FunctionTool, + _FunctionResultCarrier, # pyright: ignore[reportPrivateUsage] + _FunctionResultPayloadBudget, # pyright: ignore[reportPrivateUsage] +) from ._types import ( ChatOptions, Content, @@ -90,6 +99,7 @@ class MCPSpecificApproval(TypedDict, total=False): _MCP_REMOTE_NAME_KEY = "_mcp_remote_name" _MCP_NORMALIZED_NAME_KEY = "_mcp_normalized_name" +_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY = "_mcp_tool_result_host_payload" _MCP_PROGRESSIVE_LIST_TOOL_NAME = "list_mcp_tools" _MCP_PROGRESSIVE_LOAD_TOOL_NAME = "load_tool" _MCP_PROGRESSIVE_UNLOAD_TOOL_NAME = "unload_tool" @@ -128,6 +138,255 @@ class MCPSpecificApproval(TypedDict, total=False): _MCP_INJECTED_HEADER_KEYS_EXTENSION = "agent_framework.mcp_injected_header_keys" MCP_DEFAULT_TIMEOUT = 30 MCP_DEFAULT_SSE_READ_TIMEOUT = 60 * 5 +_DEFAULT_MCP_HOST_PAYLOAD_SIZE_BYTES = 1024 * 1024 + + +@dataclass +class _MCPHostPayloadCapture: + """Collect one generated MCP result without affecting its public return shape.""" + + max_size_bytes: int | None + aggregate_budget: _FunctionResultPayloadBudget | None + host_payload: dict[str, Any] | None = None + meta: dict[str, Any] | None = None + recorded: bool = False + meta_prepared: bool = False + + def prepare_meta(self, mcp_type: Any) -> dict[str, Any] | None: + if not self.meta_prepared: + self.meta = _mcp_tool_result_meta(mcp_type, max_size_bytes=self.max_size_bytes) + self.meta_prepared = True + return self.meta + + def record(self, mcp_type: Any) -> None: + if self.recorded: + return + self.recorded = True + self.prepare_meta(mcp_type) + + effective_limit = self.max_size_bytes + if self.aggregate_budget is not None: + remaining = self.aggregate_budget.remaining(self.max_size_bytes) + if remaining == 0: + logger.warning("Omitting MCP Host payload because the request retention budget is exhausted.") + return + if remaining is not None: + effective_limit = min(effective_limit, remaining) if effective_limit is not None else remaining + + host_payload = _mcp_tool_result_host_payload(mcp_type, max_size_bytes=effective_limit) + if host_payload is None: + return + encoded_size = len(json.dumps(host_payload).encode("utf-8")) + if self.aggregate_budget is not None and not self.aggregate_budget.reserve(encoded_size, self.max_size_bytes): + logger.warning("Omitting MCP Host payload because the request retention budget is exhausted.") + return + self.host_payload = host_payload + + def to_carrier(self) -> _FunctionResultCarrier: + additional_properties: dict[str, Any] = {} + if self.meta is not None: + additional_properties["_meta"] = self.meta + if self.host_payload is not None: + additional_properties[_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY] = self.host_payload + return _FunctionResultCarrier( + additional_properties=additional_properties, + item_additional_properties={"_meta": self.meta} if self.meta is not None else {}, + exclusive_outer_keys=frozenset({_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY}), + exclusive_item_keys=frozenset({"_meta"}), + result_already_parsed=True, + ) + + def prepare_model_result(self, parsed: str | list[Content]) -> list[Content]: + """Apply bounded metadata before security middleware sees generated output.""" + items = [Content.from_text(parsed)] if isinstance(parsed, str) else list(parsed) + for index, item in enumerate(items): + updated_item = copy(item) + updated_item.additional_properties = dict(updated_item.additional_properties) + updated_item.additional_properties.pop(_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY, None) + if self.meta is not None: + updated_item.additional_properties["_meta"] = self.meta + else: + updated_item.additional_properties.pop("_meta", None) + items[index] = updated_item + return items + + +_mcp_host_payload_capture: contextvars.ContextVar[_MCPHostPayloadCapture | None] = contextvars.ContextVar( + "_mcp_host_payload_capture", + default=None, +) + + +class _EncodedSizeBudget: + """Track JSON bytes and abort as soon as an untrusted value exceeds its budget.""" + + def __init__(self, limit: int) -> None: + self.remaining = limit + + def consume(self, size: int) -> None: + self.remaining -= size + if self.remaining < 0: + raise OverflowError + + +def _consume_json_string(value: str, budget: _EncodedSizeBudget) -> None: + budget.consume(2) + for char in value: + codepoint = ord(char) + if char in {'"', "\\"} or char in {"\b", "\f", "\n", "\r", "\t"}: + budget.consume(2) + elif codepoint < 0x20: + budget.consume(6) + elif codepoint > 0xFFFF: + budget.consume(12) + elif codepoint > 0x7F: + budget.consume(6) + else: + budget.consume(1) + + +def _consume_json_size(value: Any, budget: _EncodedSizeBudget) -> None: + if value is None: + budget.consume(4) + return + if value is True: + budget.consume(4) + return + if value is False: + budget.consume(5) + return + if isinstance(value, str): + _consume_json_string(value, budget) + return + if isinstance(value, (bytes, bytearray)): + budget.consume(2 + 4 * ((len(value) + 2) // 3)) + return + if isinstance(value, (int, float)): + budget.consume(len(json.dumps(value))) + return + if isinstance(value, (datetime, date)): + _consume_json_string(value.isoformat(), budget) + return + if callable(getattr(value, "unicode_string", None)): + _consume_json_string(str(value), budget) + return + if isinstance(value, Mapping): + budget.consume(2) + for index, (key, item) in enumerate(cast(Mapping[Any, Any], value).items()): + if index: + budget.consume(2) + _consume_json_string(str(key), budget) + budget.consume(2) + _consume_json_size(item, budget) + return + if isinstance(value, Sequence): + budget.consume(2) + for index, item in enumerate(cast(Sequence[Any], value)): + if index: + budget.consume(2) + _consume_json_size(item, budget) + return + + model_fields_raw = getattr(value.__class__, "model_fields", None) + if isinstance(model_fields_raw, Mapping): + model_fields = cast(Mapping[str, Any], model_fields_raw) + budget.consume(2) + field_count = 0 + for name, field_info in model_fields.items(): + item = getattr(value, name, None) + if item is None or getattr(field_info, "exclude", False): + continue + if field_count: + budget.consume(2) + alias = getattr(field_info, "serialization_alias", None) or getattr(field_info, "alias", None) or name + _consume_json_string(str(alias), budget) + budget.consume(2) + _consume_json_size(item, budget) + field_count += 1 + model_extra_raw = getattr(value, "model_extra", None) + if isinstance(model_extra_raw, Mapping): + for key, item in cast(Mapping[Any, Any], model_extra_raw).items(): + if item is None: + continue + if field_count: + budget.consume(2) + _consume_json_string(str(key), budget) + budget.consume(2) + _consume_json_size(item, budget) + field_count += 1 + return + + _consume_json_string(str(value), budget) + + +def _json_size_exceeds(value: Any, limit: int) -> bool: + try: + _consume_json_size(value, _EncodedSizeBudget(limit)) + except OverflowError: + return True + return False + + +def _mcp_tool_result_host_payload( + mcp_type: Any, + *, + max_size_bytes: int | None, +) -> dict[str, Any] | None: + """Return a bounded, JSON-safe copy of a complete MCP result.""" + model_dump = getattr(mcp_type, "model_dump", None) + if not callable(model_dump): + return None + if max_size_bytes is not None and _json_size_exceeds(mcp_type, max_size_bytes): + logger.warning( + "Omitting MCP Host payload because its encoded size exceeds max_host_payload_size_bytes=%d.", + max_size_bytes, + ) + return None + try: + dumped = model_dump(by_alias=True, exclude_none=True, mode="json", fallback=str) + except ValueError: + dumped = model_dump(by_alias=True, exclude_none=True) + if not isinstance(dumped, Mapping): + return None + host_payload = cast(dict[str, Any], make_json_safe(dict(cast(Mapping[str, Any], dumped)))) + if max_size_bytes is not None and len(json.dumps(host_payload).encode("utf-8")) > max_size_bytes: + logger.warning( + "Omitting MCP Host payload because its encoded size exceeds max_host_payload_size_bytes=%d.", + max_size_bytes, + ) + return None + return host_payload + + +def _mcp_tool_result_meta( + mcp_type: Any, + *, + max_size_bytes: int | None, +) -> dict[str, Any] | None: + """Return a separately bounded, JSON-safe copy of MCP result metadata.""" + raw_meta = getattr(mcp_type, "meta", None) + if not isinstance(raw_meta, Mapping): + return None + if max_size_bytes is not None and _json_size_exceeds(raw_meta, max_size_bytes): + logger.warning( + "Omitting MCP result _meta because its encoded size exceeds max_host_payload_size_bytes=%d.", + max_size_bytes, + ) + return None + meta = cast(dict[str, Any], to_jsonable_python(dict(cast(Mapping[str, Any], raw_meta)), fallback=str)) + if max_size_bytes is not None and len(json.dumps(meta).encode("utf-8")) > max_size_bytes: + logger.warning( + "Omitting MCP result _meta because its encoded size exceeds max_host_payload_size_bytes=%d.", + max_size_bytes, + ) + return None + return meta + + +def _capture_mcp_tool_result(mcp_type: Any) -> None: + capture = _mcp_host_payload_capture.get() + if capture is not None: + capture.record(mcp_type) class _MCPHeaderScopedClient: @@ -297,7 +556,18 @@ async def _call_tool_with_runtime_kwargs( call_kwargs["_meta"] = trusted_meta else: call_kwargs.pop("_meta", None) - return await mcp_tool.call_tool(remote_tool_name, **call_kwargs) + raw_budget = ctx.metadata.get(_FUNCTION_RESULT_PAYLOAD_BUDGET_CONTEXT_KEY) + capture = _MCPHostPayloadCapture( + max_size_bytes=mcp_tool.max_host_payload_size_bytes, + aggregate_budget=raw_budget if isinstance(raw_budget, _FunctionResultPayloadBudget) else None, + ) + token = _mcp_host_payload_capture.set(capture) + try: + parsed = await mcp_tool.call_tool(remote_tool_name, **call_kwargs) + return capture.prepare_model_result(parsed) + finally: + _mcp_host_payload_capture.reset(token) + ctx.metadata[_FUNCTION_RESULT_CARRIER_CONTEXT_KEY] = capture.to_carrier() return _call_tool_with_runtime_kwargs @@ -541,6 +811,8 @@ def __init__( additional_tool_argument_names: Sequence[str] | Mapping[str, Sequence[str]] | None = None, use_progressive_disclosure: bool = False, always_load: Collection[str] | None = None, + *, + max_host_payload_size_bytes: int | None = _DEFAULT_MCP_HOST_PAYLOAD_SIZE_BYTES, ) -> None: """Initialize the MCP Tool base. @@ -607,6 +879,9 @@ def __init__( always_load: MCP tool names to keep visible from the start when progressive disclosure is enabled. Names use the same safe matching rules as ``allowed_tools``; unmatched entries are ignored. + max_host_payload_size_bytes: Maximum encoded size of the complete MCP result retained + for Host transports. Oversized payloads are omitted from the Host channel while + the parsed model result is preserved. Set to ``None`` to disable the limit. """ if use_progressive_disclosure and not load_tools: raise ValueError("use_progressive_disclosure=True requires load_tools=True.") @@ -617,6 +892,8 @@ def __init__( object_name="MCP progressive disclosure", category=ExperimentalWarning, ) + if max_host_payload_size_bytes is not None and max_host_payload_size_bytes <= 0: + raise ValueError("max_host_payload_size_bytes must be positive or None.") self.name = name self.description = description or "" self.approval_mode = approval_mode @@ -627,6 +904,7 @@ def __init__( self.parse_tool_results = parse_tool_results self.load_prompts_flag = load_prompts self.parse_prompt_results = parse_prompt_results + self.max_host_payload_size_bytes = max_host_payload_size_bytes # Defer constructing the default MCPTaskOptions so the experimental warning # only fires when LRO is actually engaged (lazy-resolved by _effective_task_options). self._task_options_explicit: MCPTaskOptions | None = task_options @@ -750,13 +1028,19 @@ def _parse_tool_result_from_mcp( to derive per-item security labels. The sentinel is intentionally generic so any MCP server's ``_meta`` keys (current or future) can be interpreted by higher-level code. + + Generated MCP functions also preserve the complete MCP result under a + private core-owned ``additional_properties`` marker for Host transports. + This does not change which content is selected for the model. """ from mcp import types - raw_meta = mcp_type.meta - meta: dict[str, Any] | None = dict(raw_meta) if isinstance(raw_meta, Mapping) else None - # Stamp the server ``_meta`` payload directly via additional_properties on - # each newly constructed Content; empty when the server provided no meta. + capture = _mcp_host_payload_capture.get() + if capture is not None: + meta = capture.prepare_meta(mcp_type) + else: + raw_meta = mcp_type.meta + meta = dict(raw_meta) if isinstance(raw_meta, Mapping) else None additional_kwargs: dict[str, Any] = {"additional_properties": {"_meta": meta}} if meta else {} result: list[Content] = [] @@ -801,7 +1085,7 @@ def _parse_tool_result_from_mcp( result.append(Content.from_text(str(item), **additional_kwargs)) if mcp_type.structuredContent is not None: - result.append(Content.from_text(json.dumps(mcp_type.structuredContent, default=str))) + result.append(Content.from_text(json.dumps(mcp_type.structuredContent, default=str), **additional_kwargs)) if not result: result.append(Content.from_text("null", **additional_kwargs)) @@ -2224,6 +2508,13 @@ async def call_tool(self, tool_name: str, **kwargs: Any) -> str | list[Content]: ToolExecutionException: If the MCP server is not connected, tools are not loaded, or the tool call fails. """ + return await self._call_tool(tool_name, kwargs) + + async def _call_tool( + self, + tool_name: str, + kwargs: dict[str, Any], + ) -> str | list[Content]: if not self.load_tools_flag: raise ToolExecutionException( "Tools are not loaded for this server, please set load_tools=True in the constructor." @@ -2245,7 +2536,13 @@ async def call_tool(self, tool_name: str, **kwargs: Any) -> str | list[Content]: OtelAttr.OPERATION: OtelAttr.TOOL_EXECUTION_OPERATION, }) with create_mcp_client_span("tools/call", target=tool_name, attributes=mcp_span_attrs) as span: - return await self._call_tool_with_retries(tool_name, filtered_kwargs, meta, parser, span) + return await self._call_tool_with_retries( + tool_name, + filtered_kwargs, + meta, + parser, + span, + ) async def _call_tool_with_retries( self, @@ -2264,6 +2561,7 @@ async def _call_tool_with_retries( result = await self.session.call_tool(tool_name, arguments=filtered_kwargs, meta=meta) # type: ignore if result.isError: parsed = parser(result) + _capture_mcp_tool_result(result) text = ( "\n".join(c.text for c in parsed if c.type == "text" and c.text) if isinstance(parsed, list) @@ -2273,7 +2571,9 @@ async def _call_tool_with_retries( if span.is_recording(): set_mcp_span_error(span, "tool_error", text or str(parsed)) raise ToolExecutionException(text or str(parsed)) - return parser(result) + parsed = parser(result) + _capture_mcp_tool_result(result) + return parsed except ToolExecutionException: raise except (ClosedResourceError, McpError) as call_ex: @@ -2383,6 +2683,13 @@ async def call_tool_as_task(self, tool_name: str, **kwargs: Any) -> str | list[C A list of Content items (or a string when a custom ``parse_tool_results`` callback is configured). """ + return await self._call_tool_as_task(tool_name, kwargs) + + async def _call_tool_as_task( + self, + tool_name: str, + kwargs: dict[str, Any], + ) -> str | list[Content]: from anyio import ClosedResourceError from mcp.shared.exceptions import McpError @@ -2417,13 +2724,16 @@ async def call_tool_as_task(self, tool_name: str, **kwargs: Any) -> str | list[C if fallback_result is not None: if fallback_result.isError: parsed = parser(fallback_result) + _capture_mcp_tool_result(fallback_result) text = ( "\n".join(c.text for c in parsed if c.type == "text" and c.text) if isinstance(parsed, list) else str(parsed) ) raise ToolExecutionException(text or str(parsed)) - return parser(fallback_result) + parsed = parser(fallback_result) + _capture_mcp_tool_result(fallback_result) + return parsed if task_id is None: raise ToolExecutionException(f"MCP server did not return a task_id or fallback result for '{tool_name}'.") @@ -2435,7 +2745,12 @@ async def call_tool_as_task(self, tool_name: str, **kwargs: Any) -> str | list[C async def _await_task_completion() -> str | list[Content]: terminal = await self._poll_task_until_terminal(task_id) - return await self._handle_terminal_task(tool_name, task_id, terminal, parser) + return await self._handle_terminal_task( + tool_name, + task_id, + terminal, + parser, + ) try: if max_wait_s is not None: @@ -2515,7 +2830,6 @@ async def _call_tool_as_task_create( # Inspect the raw payload: a CreateTaskResult carries `task.taskId`; # a legacy CallToolResult carries `content` and/or `isError`. raw: dict[str, Any] = lenient.model_dump(by_alias=True, exclude_none=True) - raw.pop("_meta", None) task_field = raw.get("task") if isinstance(task_field, dict): @@ -2612,13 +2926,16 @@ async def _handle_terminal_task( payload = await self._fetch_task_result(task_id) if payload.isError: parsed = parser(payload) + _capture_mcp_tool_result(payload) text = ( "\n".join(c.text for c in parsed if c.type == "text" and c.text) if isinstance(parsed, list) else str(parsed) ) raise ToolExecutionException(text or str(parsed)) - return parser(payload) + parsed = parser(payload) + _capture_mcp_tool_result(payload) + return parsed # Non-completed terminal statuses surface as ToolExecutionException so the # function-calling loop sees a normal failure for tool_name. @@ -2650,7 +2967,6 @@ async def _fetch_task_result(self, task_id: str) -> types.CallToolResult: # GetTaskPayloadResult carries the tool result via extra fields; reinterpret as CallToolResult. payload_dict = payload.model_dump(by_alias=True, exclude_none=True) - payload_dict.pop("_meta", None) try: return types.CallToolResult.model_validate(payload_dict) except ValidationError as ex: @@ -2931,6 +3247,7 @@ def __init__( additional_properties: dict[str, Any] | None = None, task_options: MCPTaskOptions | None = None, additional_tool_argument_names: Sequence[str] | Mapping[str, Sequence[str]] | None = None, + max_host_payload_size_bytes: int | None = _DEFAULT_MCP_HOST_PAYLOAD_SIZE_BYTES, **kwargs: Any, ) -> None: """Initialize the MCP stdio tool. @@ -3021,6 +3338,8 @@ def __init__( Treat those keys as visible to this server, and source credentials outside ``function_invocation_kwargs`` - for example through ``env`` - for servers whose process you do not control. + max_host_payload_size_bytes: Maximum encoded MCP result size retained for Host + transports. ``None`` disables the limit. kwargs: Any extra arguments to pass to the stdio client. """ super().__init__( @@ -3044,6 +3363,7 @@ def __init__( sampling_approval_callback=sampling_approval_callback, sampling_max_tokens=sampling_max_tokens, sampling_max_requests=sampling_max_requests, + max_host_payload_size_bytes=max_host_payload_size_bytes, ) self.command = command self.args = args or [] @@ -3129,6 +3449,7 @@ def __init__( header_provider: Callable[[dict[str, Any]], dict[str, str]] | None = None, task_options: MCPTaskOptions | None = None, additional_tool_argument_names: Sequence[str] | Mapping[str, Sequence[str]] | None = None, + max_host_payload_size_bytes: int | None = _DEFAULT_MCP_HOST_PAYLOAD_SIZE_BYTES, **kwargs: Any, ) -> None: """Initialize the MCP streamable HTTP tool. @@ -3251,6 +3572,8 @@ def __init__( ``function_invocation_kwargs``: read a ``ContextVar`` inside the provider (which still allows a different value per request), or configure a custom ``http_client``. + max_host_payload_size_bytes: Maximum encoded MCP result size retained for Host + transports. ``None`` disables the limit. kwargs: Additional keyword arguments (accepted for backward compatibility but not used). """ super().__init__( @@ -3274,6 +3597,7 @@ def __init__( sampling_approval_callback=sampling_approval_callback, sampling_max_tokens=sampling_max_tokens, sampling_max_requests=sampling_max_requests, + max_host_payload_size_bytes=max_host_payload_size_bytes, ) self.url = url self.terminate_on_close = terminate_on_close @@ -3500,6 +3824,7 @@ def __init__( additional_properties: dict[str, Any] | None = None, task_options: MCPTaskOptions | None = None, additional_tool_argument_names: Sequence[str] | Mapping[str, Sequence[str]] | None = None, + max_host_payload_size_bytes: int | None = _DEFAULT_MCP_HOST_PAYLOAD_SIZE_BYTES, **kwargs: Any, ) -> None: """Initialize the MCP WebSocket tool. @@ -3588,6 +3913,8 @@ def __init__( The same dict is shared with every MCP server attached to the run. This transport has no header hook, so source credentials outside ``function_invocation_kwargs`` for servers you do not control. + max_host_payload_size_bytes: Maximum encoded MCP result size retained for Host + transports. ``None`` disables the limit. kwargs: Any extra arguments to pass to the WebSocket client. """ super().__init__( @@ -3611,6 +3938,7 @@ def __init__( sampling_approval_callback=sampling_approval_callback, sampling_max_tokens=sampling_max_tokens, sampling_max_requests=sampling_max_requests, + max_host_payload_size_bytes=max_host_payload_size_bytes, ) self.url = url self._client_kwargs = kwargs diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index ac3fc903d99..3b298b08630 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -106,6 +106,9 @@ def _generate_function_call_occurrence_id() -> str: _ALREADY_APPROVED_APPROVAL_REQUEST_GROUPS_KEY: Final[str] = "already_approved_approval_request_groups" _PENDING_APPROVAL_REQUESTS_KEY: Final[str] = "pending_approval_requests" _FUNCTION_INVOCATION_BUDGET_STATE_KEY: Final[str] = "_function_invocation_budget_state" +_FUNCTION_RESULT_CARRIER_CONTEXT_KEY: Final[str] = "_function_result_carrier" +_FUNCTION_RESULT_PAYLOAD_BUDGET_CONTEXT_KEY: Final[str] = "_function_result_payload_budget" +_FUNCTION_RESULT_PAYLOAD_BUDGET_STATE_KEY: Final[str] = "_function_result_payload_budget" _FUNCTION_INVOCATION_LIMIT_FALLBACK_TEXT: Final[str] = ( "Function invocation limit reached before a final answer could be produced." ) @@ -115,6 +118,40 @@ def _generate_function_call_occurrence_id() -> str: ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel) +@dataclass +class _FunctionResultCarrier: + """Host-only properties retained independently of model-facing tool output.""" + + additional_properties: dict[str, Any] + item_additional_properties: dict[str, Any] + exclusive_outer_keys: frozenset[str] = frozenset() + exclusive_item_keys: frozenset[str] = frozenset() + result_already_parsed: bool = False + + +@dataclass +class _FunctionResultPayloadBudget: + """Bound retained Host payloads across one function-invocation request.""" + + limit_bytes: int = 0 + retained_bytes: int = 0 + + def remaining(self, per_result_limit: int | None) -> int | None: + if per_result_limit is None: + return None + self.limit_bytes = max(self.limit_bytes, per_result_limit) + return max(self.limit_bytes - self.retained_bytes, 0) + + def reserve(self, size_bytes: int, per_result_limit: int | None) -> bool: + if per_result_limit is None: + return True + remaining = self.remaining(per_result_limit) + if remaining is None or size_bytes > remaining: + return False + self.retained_bytes += size_bytes + return True + + class _SkipParsingSentinel: """Sentinel signaling that :meth:`FunctionTool.invoke` should return the raw value. @@ -722,11 +759,21 @@ async def invoke( logger.info(f"Function {self.name} succeeded.") logger.debug(f"Function result: {type(result).__name__}") return result - try: - parsed = parser(result) - except Exception: - logger.warning(f"Function {self.name}: result parser failed, falling back to str().") - parsed = [Content.from_text(str(result))] + carrier = ( + effective_context.metadata.get(_FUNCTION_RESULT_CARRIER_CONTEXT_KEY) if effective_context else None + ) + if ( + isinstance(carrier, _FunctionResultCarrier) + and carrier.result_already_parsed + and configured_parser is None + ): + parsed = result + else: + try: + parsed = parser(result) + except Exception: + logger.warning(f"Function {self.name}: result parser failed, falling back to str().") + parsed = [Content.from_text(str(result))] if isinstance(parsed, str): parsed = [Content.from_text(parsed)] logger.info(f"Function {self.name} succeeded.") @@ -787,11 +834,21 @@ async def invoke( if emit_tool_call_attrs: span.set_attribute(OtelAttr.TOOL_RESULT, result_str) return result - try: - parsed = parser(result) - except Exception: - logger.warning(f"Function {self.name}: result parser failed, falling back to str().") - parsed = [Content.from_text(str(result))] + carrier = ( + effective_context.metadata.get(_FUNCTION_RESULT_CARRIER_CONTEXT_KEY) if effective_context else None + ) + if ( + isinstance(carrier, _FunctionResultCarrier) + and carrier.result_already_parsed + and configured_parser is None + ): + parsed = result + else: + try: + parsed = parser(result) + except Exception: + logger.warning(f"Function {self.name}: result parser failed, falling back to str().") + parsed = [Content.from_text(str(result))] if isinstance(parsed, str): parsed = [Content.from_text(parsed)] logger.info(f"Function {self.name} succeeded.") @@ -1440,9 +1497,8 @@ def _function_execution_error_result( tool_name: str, exception: Exception, config: FunctionInvocationConfiguration, + context: FunctionInvocationContext | None = None, ) -> Content: - from ._types import Content - logger.warning( "Function '%s' raised an exception; returning an error result to the model. " "Set include_detailed_errors=True for the full detail. Exception: %r", @@ -1452,12 +1508,57 @@ def _function_execution_error_result( message = "Error: Function failed." if config.get("include_detailed_errors", False): message = f"{message} Exception: {exception}" - return Content.from_function_result( + return _finalize_function_result( call_id=function_call.call_id, # type: ignore[arg-type] result=message, exception=str(exception), - additional_properties=function_call.additional_properties, + base_additional_properties=function_call.additional_properties, + context=context, + ) + + +def _finalize_function_result( + *, + call_id: str, + result: Any, + base_additional_properties: Mapping[str, Any] | None = None, + exception: str | None = None, + context: FunctionInvocationContext | None = None, +) -> Content: + """Build the stable function-result wrapper and apply private Host metadata.""" + from ._types import Content + + carrier: _FunctionResultCarrier | None = None + if context is not None: + raw_carrier = context.metadata.pop(_FUNCTION_RESULT_CARRIER_CONTEXT_KEY, None) + if isinstance(raw_carrier, _FunctionResultCarrier): + carrier = raw_carrier + + additional_properties = dict(base_additional_properties or {}) + if carrier is not None: + for key in carrier.exclusive_outer_keys: + additional_properties.pop(key, None) + additional_properties.update(carrier.additional_properties) + + function_result = Content.from_function_result( + call_id=call_id, + result=result, + exception=exception, + additional_properties=additional_properties, ) + if carrier is None or function_result.items is None: + return function_result + + updated_items = list(function_result.items) + for index, item in enumerate(updated_items): + updated_item = copy.copy(item) + updated_item.additional_properties = dict(updated_item.additional_properties) + for key in carrier.exclusive_outer_keys | carrier.exclusive_item_keys: + updated_item.additional_properties.pop(key, None) + updated_item.additional_properties.update(carrier.item_additional_properties) + updated_items[index] = updated_item + function_result.items = updated_items + return function_result async def _auto_invoke_function( @@ -1469,6 +1570,7 @@ async def _auto_invoke_function( invocation_session: AgentSession | None = None, middleware_pipeline: FunctionMiddlewarePipeline | None = None, live_tools: list[ToolTypes] | None = None, + host_payload_budget: _FunctionResultPayloadBudget | None = None, ) -> Content: """Invoke a function call requested by the agent, applying middleware that is defined. @@ -1483,6 +1585,7 @@ async def _auto_invoke_function( middleware_pipeline: Optional middleware pipeline to apply during execution. live_tools: The live, mutable tools list for the current agent run, exposed on the FunctionInvocationContext so tools can add/remove tools at runtime. + host_payload_budget: Shared request budget for retained Host-only function result payloads. Returns: The function result content. @@ -1572,8 +1675,8 @@ async def _auto_invoke_function( if middleware_pipeline is None or not middleware_pipeline.has_middlewares: # No middleware - execute directly + direct_context = None try: - direct_context = None if getattr(tool, "_context_parameter_name", None): direct_context = FunctionInvocationContext( function=tool, @@ -1582,22 +1685,25 @@ async def _auto_invoke_function( kwargs=runtime_kwargs.copy(), tools=live_tools, ) + if host_payload_budget is not None: + direct_context.metadata[_FUNCTION_RESULT_PAYLOAD_BUDGET_CONTEXT_KEY] = host_payload_budget function_result = await tool.invoke( arguments=args, context=direct_context, tool_call_id=function_call_content.call_id, ) - return Content.from_function_result( + return _finalize_function_result( call_id=function_call_content.call_id, # type: ignore[arg-type] result=function_result, - additional_properties=function_call_content.additional_properties, + base_additional_properties=function_call_content.additional_properties, + context=direct_context, ) except (MiddlewareFailure, UserInputRequiredException): # Explicit control-flow signals escape the loop; only ordinary exceptions # are absorbed into tool-error results below. raise except Exception as exc: - return _function_execution_error_result(function_call_content, tool.name, exc, config) + return _function_execution_error_result(function_call_content, tool.name, exc, config, direct_context) # Execute through middleware pipeline if available middleware_context = FunctionInvocationContext( function=tool, @@ -1606,6 +1712,8 @@ async def _auto_invoke_function( kwargs=runtime_kwargs.copy(), tools=live_tools, ) + if host_payload_budget is not None: + middleware_context.metadata[_FUNCTION_RESULT_PAYLOAD_BUDGET_CONTEXT_KEY] = host_payload_budget call_id = function_call_content.call_id if call_id is None: @@ -1641,7 +1749,12 @@ async def final_function_handler(context_obj: Any) -> Any: if isinstance(function_result, Content) and function_result.type == "function_approval_request": return function_result - return Content.from_function_result(call_id=call_id, result=function_result) + return _finalize_function_result( + call_id=call_id, + result=function_result, + base_additional_properties=function_call_content.additional_properties, + context=middleware_context, + ) except MiddlewareTermination as term_exc: # Re-raise to signal loop termination, but first capture any result set by middleware if middleware_context.result is not None: @@ -1654,10 +1767,11 @@ async def final_function_handler(context_obj: Any) -> Any: term_exc.result = middleware_context.result else: # Store result in exception for caller to extract - term_exc.result = Content.from_function_result( + term_exc.result = _finalize_function_result( call_id=call_id, result=middleware_context.result, - additional_properties=function_call_content.additional_properties, + base_additional_properties=function_call_content.additional_properties, + context=middleware_context, ) raise except (MiddlewareFailure, UserInputRequiredException): @@ -1666,7 +1780,7 @@ async def final_function_handler(context_obj: Any) -> Any: # relying on the tool-error conversion below, and it propagates to the caller. raise except Exception as exc: - return _function_execution_error_result(function_call_content, tool.name, exc, config) + return _function_execution_error_result(function_call_content, tool.name, exc, config, middleware_context) def _get_tool_map( @@ -1698,6 +1812,7 @@ async def _execute_single_function_call( invocation_session: AgentSession | None, middleware_pipeline: FunctionMiddlewarePipeline | None, live_tools: list[ToolTypes] | None, + host_payload_budget: _FunctionResultPayloadBudget | None, ) -> tuple[list[Content], bool]: from ._middleware import MiddlewareTermination from ._sessions import _suspend_run_persistence_gate # pyright: ignore[reportPrivateUsage] @@ -1719,6 +1834,7 @@ async def _execute_single_function_call( middleware_pipeline=middleware_pipeline, config=config, live_tools=live_tools, + host_payload_budget=host_payload_budget, ) return [result], False except MiddlewareTermination as exc: @@ -1757,6 +1873,7 @@ async def _try_execute_function_call_groups( config: FunctionInvocationConfiguration, invocation_session: AgentSession | None = None, middleware_pipeline: FunctionMiddlewarePipeline | None = None, + host_payload_budget: _FunctionResultPayloadBudget | None = None, ) -> tuple[list[list[Content]], bool]: """Execute multiple function calls concurrently while preserving per-call result groups. @@ -1767,6 +1884,7 @@ async def _try_execute_function_call_groups( config: Configuration for function invocation. invocation_session: The agent session for this invocation, if any. middleware_pipeline: Optional middleware pipeline to apply during execution. + host_payload_budget: Shared request budget for retained Host-only function result payloads. Returns: A tuple of: @@ -1885,6 +2003,7 @@ async def _try_execute_function_call_groups( invocation_session=invocation_session, middleware_pipeline=middleware_pipeline, live_tools=live_tools, + host_payload_budget=host_payload_budget, ), ) for function_call in function_calls @@ -1955,6 +2074,7 @@ async def _execute_function_calls( config: FunctionInvocationConfiguration, invocation_session: AgentSession | None = None, middleware_pipeline: FunctionMiddlewarePipeline | None = None, + host_payload_budget: _FunctionResultPayloadBudget | None = None, ) -> _FunctionExecutionBatch: tools = _extract_tools(options) if not tools: @@ -1966,6 +2086,7 @@ async def _execute_function_calls( invocation_session=invocation_session, middleware_pipeline=middleware_pipeline, config=config, + host_payload_budget=host_payload_budget, ) return _FunctionExecutionBatch( result_groups=result_groups, @@ -3858,6 +3979,13 @@ def get_response( # setdefault preserves the original timestamp across approval re-entries so that # max_duration_seconds measures cumulative elapsed time, not just the current segment. budget_state.setdefault("start_time", perf_counter()) + raw_host_payload_budget = budget_state.get(_FUNCTION_RESULT_PAYLOAD_BUDGET_STATE_KEY) + host_payload_budget = ( + raw_host_payload_budget + if isinstance(raw_host_payload_budget, _FunctionResultPayloadBudget) + else _FunctionResultPayloadBudget() + ) + budget_state[_FUNCTION_RESULT_PAYLOAD_BUDGET_STATE_KEY] = host_payload_budget max_errors = self.function_invocation_configuration.get( "max_consecutive_errors_per_request", DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST ) @@ -3879,6 +4007,7 @@ def get_response( config=self.function_invocation_configuration, invocation_session=invocation_session, middleware_pipeline=function_middleware_pipeline, + host_payload_budget=host_payload_budget, ) # Give the loop private mutable options and one shared run-local tool list for progressive tool changes. diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index f93727edb28..0784fc53a23 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -2,6 +2,7 @@ # pyright: ignore[reportPrivateUsage] import asyncio import contextlib +import inspect import json import logging import os @@ -20,6 +21,8 @@ from pydantic import AnyUrl, BaseModel from agent_framework import ( + ChatResponse, + ChatResponseUpdate, Content, FunctionInvocationContext, FunctionMiddleware, @@ -32,16 +35,26 @@ from agent_framework._feature_stage import _WARNED_FEATURES, ExperimentalFeature, ExperimentalWarning from agent_framework._mcp import ( _MCP_HEADER_OWNER_EXTENSION, + _MCP_TOOL_RESULT_HOST_PAYLOAD_KEY, MCPTool, _build_prefixed_mcp_name, _describe_error, _get_input_model_from_mcp_prompt, + _json_size_exceeds, + _make_mcp_tool_caller, + _mcp_tool_result_host_payload, + _mcp_tool_result_meta, _normalize_additional_tool_argument_names, _normalize_mcp_name, _should_propagate_cancelled_error, logger, ) from agent_framework._middleware import FunctionMiddlewarePipeline +from agent_framework._tools import ( + _auto_invoke_function, + _FunctionResultPayloadBudget, + normalize_function_invocation_configuration, +) from agent_framework.exceptions import ToolException, ToolExecutionException # Integration test skip condition @@ -62,6 +75,34 @@ def _mcp_result_to_text(result: str | list[Content]) -> str: _HELPER_MCP_TOOL = MCPTool(name="helper") # type: ignore[abstract] +async def _call_generated_mcp_tool( + tool: MCPTool, + tool_name: str, + *, + result_parser: Any = None, + middleware_pipeline: FunctionMiddlewarePipeline | None = None, + host_payload_budget: _FunctionResultPayloadBudget | None = None, + **kwargs: Any, +) -> Content: + function_kwargs: dict[str, Any] = {} + if result_parser is not None: + function_kwargs["result_parser"] = result_parser + function = FunctionTool( + name=tool_name, + description="", + func=_make_mcp_tool_caller(tool, tool_name), + input_model={"type": "object", "properties": {name: {} for name in kwargs}}, + **function_kwargs, + ) + return await _auto_invoke_function( + Content.from_function_call(call_id=f"call-{tool_name}", name=tool_name, arguments=kwargs), + config=normalize_function_invocation_configuration(None), + tool_map={tool_name: function}, + middleware_pipeline=middleware_pipeline, + host_payload_budget=host_payload_budget, + ) + + def _reset_progressive_mcp_warning_state() -> None: _WARNED_FEATURES.discard((ExperimentalWarning, ExperimentalFeature.PROGRESSIVE_TOOLS.value)) @@ -503,6 +544,512 @@ def test_parse_tool_result_from_mcp_structured_content_with_text(): assert parsed == {"data": [1, 2, 3]} +async def test_generated_mcp_tool_preserves_complete_host_payload_once() -> None: + """The generated FunctionTool path retains one complete, persistent Host payload.""" + mcp_result = types.CallToolResult( + content=[types.TextContent(type="text", text="Summary")], + structuredContent={"image_url": "https://example.test/widget.png"}, + isError=False, + _meta={"widget": "image"}, + ) + tool = MCPTool(name="helper") # type: ignore[abstract] + tool.session = Mock() + tool.session.call_tool = AsyncMock(return_value=mcp_result) + + function_result = await _call_generated_mcp_tool(tool, "widget") + assert function_result.items is not None + expected_host_payload = { + "_meta": {"widget": "image"}, + "content": [{"type": "text", "text": "Summary"}], + "structuredContent": {"image_url": "https://example.test/widget.png"}, + "isError": False, + } + + assert [item.additional_properties["_meta"] for item in function_result.items] == [{"widget": "image"}] * 2 + assert function_result.additional_properties[_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY] == expected_host_payload + assert all(_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY not in item.additional_properties for item in function_result.items) + restored = Content.from_dict(function_result.to_dict()) + + assert restored.result == function_result.result + assert restored.items is not None + assert restored.additional_properties[_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY] == expected_host_payload + assert all(_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY not in item.additional_properties for item in restored.items) + + +async def test_generated_mcp_host_payload_replaces_duplicate_private_markers() -> None: + """Only core's outer complete payload marker survives.""" + stale = {_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY: {"stale": True}} + mcp_result = types.CallToolResult(content=[types.TextContent(type="text", text="current")]) + tool = MCPTool( # type: ignore[abstract] + name="helper", + parse_tool_results=lambda _: [ + Content.from_text("one", additional_properties=stale), + Content.from_text("two", additional_properties=stale), + ], + ) + tool.session = Mock() + tool.session.call_tool = AsyncMock(return_value=mcp_result) + + function_result = await _call_generated_mcp_tool(tool, "widget") + + assert function_result.additional_properties[_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY]["content"] == [ + {"type": "text", "text": "current"} + ] + assert function_result.items is not None + assert all(_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY not in item.additional_properties for item in function_result.items) + + +async def test_custom_mcp_result_parser_preserves_direct_shape_and_generated_host_payload() -> None: + """A custom parser controls model content while generated calls retain the Host payload.""" + mcp_result = types.CallToolResult( + content=[types.TextContent(type="text", text="Server summary")], + structuredContent={"image_url": "https://example.test/widget.png"}, + _meta={"source": "server"}, + ) + tool = MCPTool(name="helper", parse_tool_results=lambda _: "Custom model summary") # type: ignore[abstract] + tool.session = Mock() + tool.session.call_tool = AsyncMock(return_value=mcp_result) + + direct_result = await tool.call_tool("widget") + function_result = await _call_generated_mcp_tool(tool, "widget") + + assert direct_result == "Custom model summary" + assert function_result.items is not None + assert [item.text for item in function_result.items] == ["Custom model summary"] + assert function_result.items[0].additional_properties["_meta"] == {"source": "server"} + assert function_result.additional_properties[_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY]["structuredContent"] == { + "image_url": "https://example.test/widget.png" + } + + +async def test_oversized_mcp_host_payload_is_omitted_without_changing_model_result( + caplog: pytest.LogCaptureFixture, +) -> None: + """An oversized Host payload is omitted while bounded model content and metadata survive.""" + mcp_result = types.CallToolResult( + content=[types.TextContent(type="text", text="Server summary")], + structuredContent={"widget_data": "x" * 1024}, + _meta={"source": "oversized"}, + ) + tool = MCPTool( # type: ignore[abstract] + name="helper", + parse_tool_results=lambda _: "Bounded model summary", + max_host_payload_size_bytes=128, + ) + tool.session = Mock() + tool.session.call_tool = AsyncMock(return_value=mcp_result) + + with caplog.at_level(logging.WARNING): + function_result = await _call_generated_mcp_tool(tool, "widget") + + assert function_result.items is not None + assert [item.text for item in function_result.items] == ["Bounded model summary"] + assert function_result.items[0].additional_properties["_meta"] == {"source": "oversized"} + assert _MCP_TOOL_RESULT_HOST_PAYLOAD_KEY not in function_result.additional_properties + assert "Omitting MCP Host payload" in caplog.text + + +def test_mcp_host_payload_size_limit_must_be_positive_or_none() -> None: + with pytest.raises(ValueError, match="positive or None"): + MCPTool(name="invalid", max_host_payload_size_bytes=0) # type: ignore[abstract] + + unlimited = MCPTool(name="unlimited", max_host_payload_size_bytes=None) # type: ignore[abstract] + assert unlimited.max_host_payload_size_bytes is None + + +def test_mcp_host_payload_size_boundary_uri_serialization_and_early_abort( + monkeypatch: pytest.MonkeyPatch, +) -> None: + mcp_result = types.CallToolResult( + content=[types.TextContent(type="text", text='Escaped "\n\u2603" text')], + structuredContent={"widget_data": "x" * 1024}, + ) + encoded_size = len(json.dumps(mcp_result.model_dump(by_alias=True, exclude_none=True)).encode("utf-8")) + + assert _json_size_exceeds(mcp_result, encoded_size - 1) is True + assert _json_size_exceeds(mcp_result, encoded_size) is False + + uri_result = types.CallToolResult( + content=[ + types.ResourceLink( + type="resource_link", + uri=AnyUrl("file:///abc"), + name="resource", + ) + ] + ) + uri_payload = _mcp_tool_result_host_payload(uri_result, max_size_bytes=None) + assert uri_payload is not None + assert uri_payload["content"][0]["uri"] == "file:///abc" + uri_size = len(json.dumps(uri_payload).encode("utf-8")) + assert _mcp_tool_result_host_payload(uri_result, max_size_bytes=uri_size) == uri_payload + assert _mcp_tool_result_host_payload(uri_result, max_size_bytes=uri_size - 1) is None + + def fail_if_dumped(*_args: Any, **_kwargs: Any) -> Any: + raise AssertionError("oversized payload must be rejected before model_dump") + + monkeypatch.setattr(types.CallToolResult, "model_dump", fail_if_dumped) + assert _mcp_tool_result_host_payload(mcp_result, max_size_bytes=128) is None + + +async def test_generated_mcp_error_preserves_complete_host_payload_on_function_result() -> None: + """An MCP error keeps its Host payload after generic function error conversion.""" + mcp_result = types.CallToolResult( + content=[types.TextContent(type="text", text="Widget failed")], + structuredContent={"reason": "invalid input"}, + isError=True, + _meta={"source": "server"}, + ) + tool = MCPTool(name="helper") # type: ignore[abstract] + tool.session = Mock() + tool.session.call_tool = AsyncMock(return_value=mcp_result) + + function_result = await _call_generated_mcp_tool(tool, "widget") + host_payload = function_result.additional_properties[_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY] + + assert function_result.result == "Error: Function failed." + assert function_result.additional_properties["_meta"] == {"source": "server"} + assert host_payload["content"] == [{"type": "text", "text": "Widget failed"}] + assert host_payload["structuredContent"] == {"reason": "invalid input"} + assert host_payload["isError"] is True + + +async def test_direct_mcp_calls_do_not_materialize_host_payload(monkeypatch: pytest.MonkeyPatch) -> None: + """Public direct success and error calls retain their established behavior.""" + success = types.CallToolResult(content=[types.TextContent(type="text", text="ok")]) + error = types.CallToolResult(content=[types.TextContent(type="text", text="failed")], isError=True) + tool = MCPTool( # type: ignore[abstract] + name="helper", + parse_tool_results=lambda result: cast(types.TextContent, result.content[0]).text, + ) + tool.session = Mock() + tool.session.call_tool = AsyncMock(side_effect=[success, error]) + + def fail_if_captured(*_args: Any, **_kwargs: Any) -> Any: + raise AssertionError("direct calls must not materialize Host metadata") + + monkeypatch.setattr("agent_framework._mcp._mcp_tool_result_host_payload", fail_if_captured) + monkeypatch.setattr("agent_framework._mcp._mcp_tool_result_meta", fail_if_captured) + + assert await tool.call_tool("widget") == "ok" + with pytest.raises(ToolExecutionException, match="failed"): + await tool.call_tool("widget") + + +async def test_function_tool_result_parser_cannot_discard_mcp_host_payload() -> None: + mcp_result = types.CallToolResult( + content=[types.TextContent(type="text", text="server projection")], + structuredContent={"widget": "complete"}, + _meta={"source": "server"}, + ) + tool = MCPTool(name="helper", parse_tool_results=lambda _: "MCP parser projection") # type: ignore[abstract] + tool.session = Mock() + tool.session.call_tool = AsyncMock(return_value=mcp_result) + + function_result = await _call_generated_mcp_tool( + tool, + "widget", + result_parser=lambda _: "FunctionTool parser projection", + ) + + assert function_result.result == "FunctionTool parser projection" + assert function_result.additional_properties[_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY]["structuredContent"] == { + "widget": "complete" + } + assert function_result.items is not None + assert function_result.items[0].additional_properties["_meta"] == {"source": "server"} + + +@pytest.mark.parametrize("parser_layer", ["mcp", "function"]) +async def test_empty_custom_parser_projection_remains_empty(parser_layer: str) -> None: + mcp_result = types.CallToolResult( + content=[types.TextContent(type="text", text="server projection")], + structuredContent={"widget": "complete"}, + ) + tool = MCPTool( # type: ignore[abstract] + name="helper", + parse_tool_results=(lambda _: []) if parser_layer == "mcp" else None, + ) + tool.session = Mock() + tool.session.call_tool = AsyncMock(return_value=mcp_result) + + function_result = await _call_generated_mcp_tool( + tool, + "widget", + result_parser=(lambda _: []) if parser_layer == "function" else None, + ) + + assert function_result.result == "" + assert function_result.items == [] + assert function_result.additional_properties[_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY]["structuredContent"] == { + "widget": "complete" + } + + +async def test_oversized_mcp_error_preserves_independently_bounded_meta() -> None: + mcp_result = types.CallToolResult( + content=[types.TextContent(type="text", text="failed")], + structuredContent={"large": "x" * 1024}, + isError=True, + _meta={"source": "small"}, + ) + tool = MCPTool(name="helper", max_host_payload_size_bytes=128) # type: ignore[abstract] + tool.session = Mock() + tool.session.call_tool = AsyncMock(return_value=mcp_result) + + function_result = await _call_generated_mcp_tool(tool, "widget") + + assert function_result.exception is not None + assert _MCP_TOOL_RESULT_HOST_PAYLOAD_KEY not in function_result.additional_properties + assert function_result.additional_properties["_meta"] == {"source": "small"} + assert function_result.items is not None + assert function_result.items[0].additional_properties["_meta"] == {"source": "small"} + + +def test_mcp_result_meta_has_independent_boundary_and_early_abort(monkeypatch: pytest.MonkeyPatch) -> None: + mcp_result = types.CallToolResult( + content=[types.TextContent(type="text", text="ok")], + _meta={"source": "server", "uri": AnyUrl("https://example.test/resource")}, + ) + expected = {"source": "server", "uri": "https://example.test/resource"} + encoded_size = len(json.dumps(expected).encode("utf-8")) + + assert _mcp_tool_result_meta(mcp_result, max_size_bytes=encoded_size) == expected + assert _mcp_tool_result_meta(mcp_result, max_size_bytes=encoded_size - 1) is None + + oversized = types.CallToolResult( + content=[types.TextContent(type="text", text="ok")], + _meta={"large": "x" * 1024}, + ) + + def fail_if_copied(*_args: Any, **_kwargs: Any) -> Any: + raise AssertionError("oversized _meta must be rejected before JSON-safe copying") + + monkeypatch.setattr("agent_framework._mcp.to_jsonable_python", fail_if_copied) + assert _mcp_tool_result_meta(oversized, max_size_bytes=128) is None + + +async def test_oversized_mcp_meta_is_omitted_from_host_and_model_items() -> None: + mcp_result = types.CallToolResult( + content=[types.TextContent(type="text", text="ok")], + _meta={"large": "x" * 1024}, + ) + tool = MCPTool(name="helper", max_host_payload_size_bytes=128) # type: ignore[abstract] + tool.session = Mock() + tool.session.call_tool = AsyncMock(return_value=mcp_result) + + function_result = await _call_generated_mcp_tool(tool, "widget") + + assert function_result.result == "ok" + assert "_meta" not in function_result.additional_properties + assert _MCP_TOOL_RESULT_HOST_PAYLOAD_KEY not in function_result.additional_properties + assert function_result.items is not None + assert "_meta" not in function_result.items[0].additional_properties + + +@pytest.mark.parametrize("streaming", [False, True], ids=["non-streaming", "streaming"]) +async def test_mcp_host_payload_survives_real_function_loop( + chat_client_base: Any, + streaming: bool, +) -> None: + mcp_result = types.CallToolResult( + content=[types.TextContent(type="text", text="model projection")], + structuredContent={"widget": "complete"}, + _meta={"source": "server"}, + ) + tool = MCPTool(name="helper") # type: ignore[abstract] + tool.session = Mock() + tool.session.call_tool = AsyncMock(return_value=mcp_result) + function = FunctionTool( + name="widget", + description="", + func=_make_mcp_tool_caller(tool, "widget"), + input_model={"type": "object", "properties": {}}, + ) + function_call = Content.from_function_call(call_id="call-widget", name="widget", arguments={}) + captured_model_inputs: list[list[Message]] = [] + + if streaming: + original_streaming = chat_client_base._get_streaming_response + + def capture_streaming(*, messages: list[Message], **kwargs: Any) -> Any: + captured_model_inputs.append([Message.from_dict(message.to_dict()) for message in messages]) + return original_streaming(messages=messages, **kwargs) + + chat_client_base._get_streaming_response = capture_streaming + chat_client_base.streaming_responses = [ + [ChatResponseUpdate(role="assistant", contents=[function_call], finish_reason="tool_calls")], + [ + ChatResponseUpdate( + role="assistant", + contents=[Content.from_text("done")], + finish_reason="stop", + ) + ], + ] + stream = chat_client_base.get_response( + [Message(role="user", contents=["run"])], + stream=True, + options={"tools": [function]}, + ) + updates = [update async for update in stream] + response = await stream.get_final_response() + streamed_results = [ + content for update in updates for content in update.contents if content.type == "function_result" + ] + assert len(streamed_results) == 1 + assert _MCP_TOOL_RESULT_HOST_PAYLOAD_KEY in streamed_results[0].additional_properties + else: + original_non_streaming = chat_client_base._get_non_streaming_response + + async def capture_non_streaming(*, messages: list[Message], **kwargs: Any) -> ChatResponse: + captured_model_inputs.append([Message.from_dict(message.to_dict()) for message in messages]) + return await original_non_streaming(messages=messages, **kwargs) + + chat_client_base._get_non_streaming_response = capture_non_streaming + chat_client_base.run_responses = [ + ChatResponse(messages=Message(role="assistant", contents=[function_call])), + ChatResponse(messages=Message(role="assistant", contents=["done"])), + ] + response = await chat_client_base.get_response( + [Message(role="user", contents=["run"])], + options={"tools": [function]}, + ) + + assert len(captured_model_inputs) == 2 + second_call_results = [ + content + for message in captured_model_inputs[1] + for content in message.contents + if content.type == "function_result" + ] + final_results = [ + content for message in response.messages for content in message.contents if content.type == "function_result" + ] + assert len(second_call_results) == 1 + assert len(final_results) == 1 + for function_result in [second_call_results[0], final_results[0]]: + assert function_result.additional_properties[_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY]["structuredContent"] == { + "widget": "complete" + } + assert function_result.items is not None + assert all( + _MCP_TOOL_RESULT_HOST_PAYLOAD_KEY not in item.additional_properties for item in function_result.items + ) + restored = Content.from_dict(function_result.to_dict()) + assert restored.additional_properties[_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY]["structuredContent"] == { + "widget": "complete" + } + + +@pytest.mark.parametrize( + ("size_limit", "expected_markers"), + [(512, 1), (None, 2)], + ids=["bounded", "unlimited"], +) +async def test_mcp_host_payload_has_aggregate_request_budget( + chat_client_base: Any, + size_limit: int | None, + expected_markers: int, +) -> None: + tool = MCPTool(name="helper", max_host_payload_size_bytes=size_limit) # type: ignore[abstract] + tool.session = Mock() + + async def call_tool(tool_name: str, **_kwargs: Any) -> types.CallToolResult: + return types.CallToolResult( + content=[types.TextContent(type="text", text=tool_name)], + structuredContent={"data": tool_name * 120}, + ) + + tool.session.call_tool = AsyncMock(side_effect=call_tool) + functions = [ + FunctionTool( + name=name, + description="", + func=_make_mcp_tool_caller(tool, name), + input_model={"type": "object", "properties": {}}, + ) + for name in ("one", "two") + ] + chat_client_base.run_responses = [ + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call(call_id=f"call-{function.name}", name=function.name, arguments={}) + for function in functions + ], + ) + ), + ChatResponse(messages=Message(role="assistant", contents=["done"])), + ] + + response = await chat_client_base.get_response( + [Message(role="user", contents=["run"])], + options={"tools": functions}, + ) + + function_results = [ + content for message in response.messages for content in message.contents if content.type == "function_result" + ] + retained_payloads = [ + result.additional_properties[_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY] + for result in function_results + if _MCP_TOOL_RESULT_HOST_PAYLOAD_KEY in result.additional_properties + ] + assert len(retained_payloads) == expected_markers + if size_limit is not None: + assert sum(len(json.dumps(payload).encode("utf-8")) for payload in retained_payloads) <= size_limit + + +async def test_secure_mcp_auto_hide_preserves_outer_host_payload() -> None: + from agent_framework.security import ( + IntegrityLabel, + LabelTrackingFunctionMiddleware, + _wrap_mcp_function_for_ifc, + ) + + mcp_result = types.CallToolResult( + content=[types.TextContent(type="text", text="untrusted payload")], + structuredContent={"widget": "complete"}, + _meta={"ifc": {"integrity": "untrusted", "confidentiality": "public"}}, + ) + tool = MCPTool(name="helper", parse_tool_results=lambda _: "untrusted payload") # type: ignore[abstract] + tool.session = Mock() + tool.session.call_tool = AsyncMock(return_value=mcp_result) + function = FunctionTool( + name="widget", + description="", + func=_make_mcp_tool_caller(tool, "widget"), + input_model={"type": "object", "properties": {}}, + additional_properties={ + "_mcp_remote_name": "widget", + "source_integrity": "untrusted", + "max_allowed_confidentiality": "public", + }, + ) + _wrap_mcp_function_for_ifc(function, IntegrityLabel.UNTRUSTED) + middleware = LabelTrackingFunctionMiddleware(auto_hide_untrusted=True) + + function_result = await _auto_invoke_function( + Content.from_function_call(call_id="call-widget", name="widget", arguments={}), + config=normalize_function_invocation_configuration(None), + tool_map={"widget": function}, + middleware_pipeline=FunctionMiddlewarePipeline(middleware), + host_payload_budget=_FunctionResultPayloadBudget(), + ) + + assert function_result.additional_properties[_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY]["structuredContent"] == { + "widget": "complete" + } + assert function_result.items is not None + assert len(function_result.items) == 1 + for hidden_item in function_result.items: + assert hidden_item.additional_properties["_variable_reference"] is True + assert hidden_item.additional_properties["_meta"] == mcp_result.meta + assert hidden_item.text != "untrusted payload" + + def test_parse_tool_result_from_mcp_structured_content_none(): """Test that None structuredContent does not affect results.""" mcp_result = types.CallToolResult( @@ -1757,6 +2304,16 @@ def test_mcp_transport_subclasses_accept_progressive_disclosure_options() -> Non assert websocket.always_load == ["search"] +def test_mcp_transport_subclasses_forward_host_payload_limit() -> None: + tools = [ + MCPStdioTool(name="stdio", command="python", max_host_payload_size_bytes=101), + MCPStreamableHTTPTool(name="http", url="https://example.com/mcp", max_host_payload_size_bytes=102), + MCPWebsocketTool(name="ws", url="wss://example.com/mcp", max_host_payload_size_bytes=103), + ] + + assert [tool.max_host_payload_size_bytes for tool in tools] == [101, 102, 103] + + def test_mcp_progressive_disclosure_requires_loading_tools() -> None: with pytest.raises(ValueError, match="requires load_tools=True"): MCPTool( # type: ignore[abstract] @@ -1775,9 +2332,12 @@ def test_mcp_progressive_disclosure_warns_on_construction() -> None: def test_mcp_tool_base_constructor_preserves_positional_tool_name_prefix() -> None: tool = MCPTool("test_server", "description", None, None, "prefix") # type: ignore[abstract] + parameters = inspect.signature(MCPTool.__init__).parameters assert tool.tool_name_prefix == "prefix" assert tool.use_progressive_disclosure is False + assert parameters["always_load"].kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + assert parameters["max_host_payload_size_bytes"].kind is inspect.Parameter.KEYWORD_ONLY def _progressive_tool_list_page(*, tools: list[types.Tool] | None = None) -> types.ListToolsResult: @@ -6770,19 +7330,24 @@ def provider(kwargs): await server.load_tools() func = server.functions[0] - # Build a FunctionInvocationContext with runtime kwargs, as the agent framework would - context = FunctionInvocationContext( - function=func, - arguments={"name": "Alice"}, - kwargs={"some_token": "my-secret"}, - ) - with patch.object(MCPStreamableHTTPTool, "call_tool", spy_call_tool): - result = await func.invoke(arguments={"name": "Alice"}, context=context) + result = await _auto_invoke_function( + Content.from_function_call( + call_id="call-greet", + name=func.name, + arguments={"name": "Alice"}, + ), + custom_args={"some_token": "my-secret"}, + config=normalize_function_invocation_configuration(None), + tool_map={func.name: func}, + host_payload_budget=_FunctionResultPayloadBudget(), + ) # Verify the invoke produced a result - assert isinstance(result, list) - assert result[0].text == "Hello!" + assert result.items is not None + assert result.items[0].text == "Hello!" + assert _MCP_TOOL_RESULT_HOST_PAYLOAD_KEY in result.additional_properties + assert all(_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY not in item.additional_properties for item in result.items) # Verify header_provider was called with the runtime kwargs assert len(provider_received) == 1 @@ -7017,11 +7582,21 @@ def _make_create_task_result(task_id: str = "task-1") -> types.CreateTaskResult: ) -def _make_payload(text: str = "done!", is_error: bool = False) -> types.GetTaskPayloadResult: - return types.GetTaskPayloadResult.model_validate({ +def _make_payload( + text: str = "done!", + is_error: bool = False, + structured_content: dict[str, Any] | None = None, + meta: dict[str, Any] | None = None, +) -> types.GetTaskPayloadResult: + payload: dict[str, Any] = { "content": [{"type": "text", "text": text}], "isError": is_error, - }) + } + if structured_content is not None: + payload["structuredContent"] = structured_content + if meta is not None: + payload["_meta"] = meta + return types.GetTaskPayloadResult.model_validate(payload) def _make_task_tool( @@ -7122,22 +7697,87 @@ async def test_call_tool_routes_required_through_task_lifecycle(monkeypatch: pyt monkeypatch.setattr(_mcp_module, "_MCP_TASK_MIN_POLL_INTERVAL", _mcp_module.timedelta(milliseconds=1)) tool = _make_task_tool() + tool.parse_tool_results = lambda _: "custom task summary" tool.session.send_request = AsyncMock( # type: ignore[method-assign, union-attr] # ty: ignore[invalid-assignment] side_effect=_send_request_dispatcher( ("tools/call", _make_create_task_result()), ("tasks/get", _make_task_snapshot(status="working")), ("tasks/get", _make_task_snapshot(status="completed")), - ("tasks/result", _make_payload("hello task")), + ( + "tasks/result", + _make_payload( + "hello task", + structured_content={"widget": "task"}, + meta={"source": "completed-task"}, + ), + ), ) ) - result = await tool.call_tool("slow_op", x=1) + function_result = await _call_generated_mcp_tool(tool, "slow_op", x=1) - assert _mcp_result_to_text(result) == "hello task" + assert function_result.result == "custom task summary" + assert function_result.items is not None + assert function_result.additional_properties[_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY]["structuredContent"] == { + "widget": "task" + } + assert function_result.items[0].additional_properties["_meta"] == {"source": "completed-task"} + assert function_result.additional_properties[_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY]["_meta"] == { + "source": "completed-task" + } # Plain session.call_tool must NOT be used for required tools. tool.session.call_tool.assert_not_called() # type: ignore[union-attr] # ty: ignore[unresolved-attribute] +async def test_call_tool_routes_required_through_public_task_override() -> None: + class OverriddenTaskTool(MCPTool): + def __init__(self) -> None: + super().__init__(name="override") + self.override_called = False + + async def call_tool_as_task(self, tool_name: str, **kwargs: Any) -> str | list[Content]: + self.override_called = True + return await super().call_tool_as_task(tool_name, **kwargs) + + tool = OverriddenTaskTool() # type: ignore[abstract] + tool.session = AsyncMock(spec=ClientSession) + tool._tool_task_support_by_name["slow_op"] = "required" + fallback_result = types.CallToolResult(content=[types.TextContent(type="text", text="fallback")]) + tool.session.send_request = AsyncMock( # type: ignore[method-assign, union-attr] # ty: ignore[invalid-assignment] + return_value=types.Result.model_validate(fallback_result.model_dump(by_alias=True, exclude_none=True)) + ) + + function_result = await _call_generated_mcp_tool(tool, "slow_op") + + assert function_result.result == "fallback" + assert _MCP_TOOL_RESULT_HOST_PAYLOAD_KEY in function_result.additional_properties + assert tool.override_called is True + + +async def test_call_tool_as_task_fallback_preserves_custom_parser_host_payload() -> None: + """A legacy non-task response retains the Host payload after custom parsing.""" + tool = _make_task_tool() + tool.parse_tool_results = lambda _: "custom fallback summary" + fallback_result = types.CallToolResult( + content=[types.TextContent(type="text", text="fallback")], + structuredContent={"widget": "fallback"}, + _meta={"source": "fallback"}, + ) + tool.session.send_request = AsyncMock( # type: ignore[method-assign, union-attr] # ty: ignore[invalid-assignment] + return_value=types.Result.model_validate(fallback_result.model_dump(by_alias=True, exclude_none=True)) + ) + + function_result = await _call_generated_mcp_tool(tool, "slow_op") + + assert function_result.result == "custom fallback summary" + assert function_result.items is not None + assert function_result.additional_properties[_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY]["structuredContent"] == { + "widget": "fallback" + } + assert function_result.items[0].additional_properties["_meta"] == {"source": "fallback"} + assert function_result.additional_properties[_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY]["_meta"] == {"source": "fallback"} + + async def test_call_tool_as_task_default_ttl_propagates() -> None: from datetime import timedelta @@ -7253,12 +7893,25 @@ async def test_call_tool_as_task_payload_iserror_raises() -> None: side_effect=_send_request_dispatcher( ("tools/call", _make_create_task_result()), ("tasks/get", _make_task_snapshot(status="completed")), - ("tasks/result", _make_payload("payload exploded", is_error=True)), + ( + "tasks/result", + _make_payload( + "payload exploded", + is_error=True, + structured_content={"reason": "task failed"}, + meta={"source": "failed-task"}, + ), + ), ) ) - with pytest.raises(ToolExecutionException, match="payload exploded"): - await tool.call_tool("slow_op") + function_result = await _call_generated_mcp_tool(tool, "slow_op") + + assert function_result.exception is not None + assert function_result.additional_properties["_meta"] == {"source": "failed-task"} + assert function_result.additional_properties[_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY]["structuredContent"] == { + "reason": "task failed" + } async def test_call_tool_as_task_malformed_payload_raises() -> None: From 44dc76ce818b779a2ccf4ed73e0be505ec1552c8 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:17:54 +0000 Subject: [PATCH 84/85] [BREAKING] Python: Move path normalization to common shared code (#8123) * Move path normalization to common shared code * Address PR comments * Fix build errors * Address PR comments --- python/packages/core/AGENTS.md | 6 +- .../core/agent_framework/_filesystem.py | 117 ++++++++- .../agent_framework/_harness/_file_access.py | 20 +- .../agent_framework/_harness/_file_memory.py | 45 +++- .../core/agent_framework/_harness/_memory.py | 71 +++++- .../core/agent_framework/_harness/_todo.py | 53 +--- .../core/agent_framework/_sessions.py | 65 +---- .../packages/core/agent_framework/_skills.py | 6 +- .../core/tests/core/test_filesystem.py | 229 ++++++++++++++++++ .../tests/core/test_harness_file_access.py | 11 +- .../tests/core/test_harness_file_memory.py | 141 +++++++++++ .../core/tests/core/test_harness_memory.py | 131 ++++++++++ .../core/tests/core/test_harness_todo.py | 49 ++++ .../packages/core/tests/core/test_sessions.py | 27 +++ .../packages/core/tests/core/test_skills.py | 6 +- .../gemini/tests/test_gemini_client.py | 4 +- .../context_providers/file_memory_provider.py | 24 +- 17 files changed, 869 insertions(+), 136 deletions(-) create mode 100644 python/packages/core/tests/core/test_filesystem.py diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index 447f3bbafc4..6363ff3ba00 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -16,6 +16,7 @@ agent_framework/ ├── _vectors.py # Vector store models, CRUD/search abstractions, and protocols ├── _middleware.py # Middleware system for request/response interception ├── _sessions.py # AgentSession and context provider abstractions +├── _filesystem.py # Private filesystem safety helpers (link detection, storage-key derivation) ├── _skills.py # Agent Skills system (models, executors, provider) ├── _mcp.py # Model Context Protocol support ├── _telemetry.py # User-Agent identity and internal feature-usage mask @@ -132,6 +133,7 @@ The vector store API is experimental under the shared `VECTOR_STORES` feature ID - **`AgentSession`** - Manages conversation state and session metadata - **`SessionStore`** - Experimental in-memory opaque `session_id -> AgentSession` snapshot store; reads return independent copies - **`FileSessionStore`** - Experimental msgspec file-backed session snapshot store with atomic last-writer-wins updates; JSON is the default, `serialization_format="msgpack"` enables binary MessagePack, opaque keys are encoded to portable filenames, and only syntactically malformed snapshots are quarantined (schema, version, and state-decoder failures preserve the original file) +- **Storage-key derivation (`_filesystem._storage_key_segment`)** - Every component that maps a caller-controlled identifier (session id, owner id, memory scope) onto a storage location uses this one private helper: `FileSessionStore` / `FileHistoryProvider` (`~session-`), `TodoFileStore` (`~todo-`), `MemoryFileStore` (`~memory-`), and `FileMemoryProvider` (`~scope-`). Its contract is **injectivity** — two byte-distinct identifiers must not produce the same segment, because the segment is an isolation boundary. Identifiers that are already a safe single segment (**lowercase** ASCII alnum plus `._-`, no leading `.`, no trailing `.`/space, not a Windows reserved stem) are used verbatim so on-disk layouts stay readable; everything else is encoded as **lowercase base32** under the component prefix, with a `sha256-` digest segment past a length cap (`_MAX_ENCODED_STORAGE_KEY_SEGMENT_LENGTH`). Only that digest branch weakens the contract to collision resistance. The charset is deliberately narrow so the filesystem cannot fold two distinct segments together: **non-ASCII** values are encoded because macOS APFS/HFS+ fold NFC vs NFD, and **uppercase** values are encoded (never lowercased, which would collide with a genuine lowercase identifier) because NTFS and APFS are case-insensitive by default — base32's `a-z2-7` alphabet is itself case-stable. **Never pass such an identifier through a path normalizer** (e.g. `_normalize_relative_path`) to derive storage: that mapping is lossy, and `id` / `id/` / `id\` would collide. New components must reuse this helper with their own `~`-prefixed namespace. - **`register_state_type`** - Registers custom `AgentSession.state` classes with stable, process-wide type IDs and optional mapping codecs. Provider modules own registration for their custom state types and should use package-qualified IDs. Implicit Pydantic registration remains temporarily with `DeprecationWarning`, but module-level registration is needed to guarantee cold-start restoration. - **`ServiceSessionId`** - Mapping alias for structured service-owned continuation handles used in `AgentSession.service_session_id` - **`SessionContext`** - Context object for session-scoped data during agent runs. `extend_messages(...)` can attach @@ -150,7 +152,7 @@ The vector store API is experimental under the shared `VECTOR_STORES` feature ID - **`SkillScriptRunner`** - Protocol for file-based script execution. Any callable matching `(skill, script, args) -> Any` satisfies it. Code-defined scripts do not use a runner. - **`SkillScriptArgumentParser`** - Public type alias for an optional callable `(raw args: dict | list[str] | str | None) -> dict | None` that converts the raw `args` value before an `InlineSkillScript` runs (applied before the inline list-args guard). It is an opt-in customization hook (port of .NET PR #6498) that lets callers support backends sending tool-call arguments in a non-conforming shape (e.g. vLLM JSON strings). The output is constrained to a `dict` (named keyword arguments) or `None`, because inline scripts bind arguments by keyword name. Supply it via the `argument_parser=` constructor arg on `InlineSkillScript`, `InlineSkill` (default for scripts added via `@skill.script`), or `ClassSkill` (default for scripts discovered via `@ClassSkill.script`). When `None` (the default), the raw value is used unchanged. File-based scripts are unaffected (their runner owns arg handling). - **`SkillsProvider`** - Context provider (extends `ContextProvider`) that discovers file-based skills from `SKILL.md` files and/or accepts code-defined `Skill` instances. Follows progressive disclosure: advertise → load → read resources / run scripts. By default all three tools it exposes (`load_skill`, `read_skill_resource`, `run_skill_script`) are registered with `approval_mode="always_require"`, so every skill operation needs approval. To run unattended, pass one of the static auto-approval rules to `ToolApprovalMiddleware` (via `auto_approval_rules`): `SkillsProvider.read_only_tools_auto_approval_rule` approves only the read-only tools (`load_skill`, `read_skill_resource`) while still prompting for `run_skill_script`, and `SkillsProvider.all_tools_auto_approval_rule` approves every skill tool including script execution. Both rules reject any call carrying a `server_label` so they stay scoped to this provider's local tools and never auto-approve a same-named hosted tool. Alternatively, for trusted skills, the constructor / `from_paths` kwargs `disable_load_skill_approval`, `disable_read_skill_resource_approval`, and `disable_run_skill_script_approval` (all default `False`) opt individual tools out of approval entirely by registering them with `approval_mode="never_require"` (the auto-approval rules only apply to tools that still require approval). The tool names are also exposed as class constants (`LOAD_SKILL_TOOL_NAME`, `READ_SKILL_RESOURCE_TOOL_NAME`, `RUN_SKILL_SCRIPT_TOOL_NAME`). -- **`FileSkillsSource`** - `SkillsSource` that discovers file-based skills by scanning configured root paths for `SKILL.md`. The **configured root paths define the trust boundary** and are used as given (a root may itself be a symlink); everything discovered *below* a root is link-checked and fails closed. `_discover_skill_directories` rejects any entry that is a symbolic link, junction, or other reparse point (via the shared `agent_framework._filesystem.is_link_or_reparse_point` helper) before descending into it, and rejects a directory whose `SKILL.md` is itself such a link — otherwise a link planted under a root would be adopted as the skill root, and since every later guard treats the skill root as the boundary and only inspects segments below it, the link itself would never be inspected. Resource and script discovery apply the same rule per path segment via `_has_link_or_reparse_point_in_path`. An `OSError` while inspecting an entry is treated as unsafe (skip / reject), never as "safe". +- **`FileSkillsSource`** - `SkillsSource` that discovers file-based skills by scanning configured root paths for `SKILL.md`. The **configured root paths define the trust boundary** and are used as given (a root may itself be a symlink); everything discovered *below* a root is link-checked and fails closed. `_discover_skill_directories` rejects any entry that is a symbolic link, junction, or other reparse point (via the shared `agent_framework._filesystem._is_link_or_reparse_point` helper) before descending into it, and rejects a directory whose `SKILL.md` is itself such a link — otherwise a link planted under a root would be adopted as the skill root, and since every later guard treats the skill root as the boundary and only inspects segments below it, the link itself would never be inspected. Resource and script discovery apply the same rule per path segment via `_has_link_or_reparse_point_in_path`. An `OSError` while inspecting an entry is treated as unsafe (skip / reject), never as "safe". - **`MCPSkillsSource`** - `SkillsSource` that discovers Agent Skills served over MCP by reading the well-known `skill://index.json` (SEP-2640). Index entries are dispatched by their `type` (case-insensitive): `skill-md` entries become one `MCPSkill` each (its `SKILL.md` body and sibling resources are fetched on demand via `resources/read`), and `archive` entries are downloaded as a single ZIP / TAR / gzip-TAR blob and unpacked **entirely in memory** (via the private `_ArchiveEntryLoader`) into a `FileSkill` whose `SKILL.md` body drives it and whose sibling files (matching the resource extensions, within the search depth) become in-memory `InlineSkillResource` resources. **Nothing is written to disk** — there are no temporary directories to create, own, or prune (this is a deliberate divergence from .NET, which extracts archives to disk; it removes the temp-dir leak and the dangerous prune-of-unowned-subdirs footgun). Entries whose type has no handler (e.g. `mcp-resource-template`) are skipped. **MCP-delivered scripts are never runnable**: the loader emits no `SkillScript`s, so a bundled script can at most surface as a readable resource (and only if it matches the resource extensions — `.py` is not a default resource extension). The archive `SKILL.md` frontmatter `name` must match the advertised index-entry `name` or the skill is skipped. Extraction is hardened: a `..` path-traversal ("zip-slip") member name raises via `_normalize_archive_member_name` and aborts the whole skill (like the file-count/size limits), non-regular TAR members (links/devices) are skipped, and file-count / uncompressed-size (`_read_member_with_limit`) / download-size limits are enforced. Archive behavior is configured with `archive_*` constructor kwargs (`archive_resource_extensions`, `archive_resource_search_depth`, `archive_max_file_count`, `archive_max_size_bytes`, `archive_max_uncompressed_size_bytes`) — Python uses plain kwargs, not a `*Options` object as in .NET. A non-"resource not found" error while downloading an archive propagates (so a failed `CachingSkillsSource` refresh does not overwrite a cached list with a partial result). Unlike .NET's `AgentMcpSkillsSourceOptions.RefreshInterval`, this source has no built-in refresh interval; wrap it in `CachingSkillsSource(..., refresh_interval=...)` for caching/refresh. This is a port of .NET PR #6631; the `FileSkillsSource` `script_extensions`/`resource_extensions` kwargs default to the built-in tuples and treat `None` as "use defaults" and an empty tuple as "discover none" (an empty tuple previously fell back to defaults). `FoundryToolbox.as_skills_provider()` forwards matching `archive_*` kwargs to this source. - **`SkillsSource` decorators** - Skill sources are composable: `SkillsSource` is the abstract base, with concrete sources (`InMemorySkillsSource`, `FileSkillsSource`, `MCPSkillsSource`) and decorators that wrap an inner source — `AggregatingSkillsSource` (concatenate several sources), `FilteringSkillsSource` (predicate filter), `DeduplicatingSkillsSource` (first-wins by name), and `CachingSkillsSource` (cache the inner source's skills list). `DelegatingSkillsSource` is the abstract base for decorators. **`get_skills` takes a `SkillsSourceContext`**: every source/decorator implements `async def get_skills(self, context: SkillsSourceContext) -> list[Skill]` and forwards `context` to inner sources. `SkillsSourceContext` (frozen) carries the invoking `agent` (`SupportsAgentRun`) and optional `session` (`AgentSession | None`); `SkillsProvider` builds it from `before_run`'s `agent`/`session` and passes it into the pipeline. `FilteringSkillsSource`'s predicate is context-aware: `Callable[[Skill, SkillsSourceContext], bool]` (port of .NET #6797). **Default caching is applied only to the built-in, context-independent leaf sources**: for the `Skill` / sequence-of-skills / `from_paths` constructors, `SkillsProvider` builds `DeduplicatingSkillsSource(CachingSkillsSource())` so expensive filesystem/network discovery runs once. A **caller-supplied `SkillsSource` is used as-is — never auto-wrapped in caching or deduplication** — because auto-caching a context-aware caller source in a single shared bucket would replay the first invocation's skills for later `SkillsSourceContext`s and leak skills across agents/tenants (matches .NET, whose custom-source constructor also adds no caching/dedup). Callers who want caching on a custom pipeline compose `CachingSkillsSource(inner, cache_isolation_key_selector=...)` themselves. `disable_caching=True` only affects the built-in leaf caching (it has no effect on a caller-supplied source, which is never cached). `CachingSkillsSource` shares a single in-flight fetch across concurrent callers (per cache key) and does not update its cache on a failed fetch, so the next call retries (an initial failure leaves the cache empty; a refresh failure keeps the previously cached list). By default all callers share one cache bucket; pass `cache_isolation_key_selector=Callable[[SkillsSourceContext], str | None]` to cache separately per key (e.g. per agent name) for context-aware inner sources — the key should be low-cardinality and stable, and returning `None` (or leaving the selector `None`) uses the shared bucket. By default a cached list never expires; pass `refresh_interval=timedelta(...)` (port of .NET `CachingAgentSkillsSourceOptions.RefreshInterval`) to treat a cached list as stale once it is older than the interval so the next call re-queries the inner source (useful when an inner source such as `MCPSkillsSource` changes over the process lifetime; a zero/negative interval makes every result immediately stale, and a failed refresh keeps the prior list and retries). Freshness is measured with a monotonic clock (`time.monotonic()`). `SkillsProvider.__init__` / `from_paths` expose a `cache_refresh_interval` kwarg that is threaded into the built-in `CachingSkillsSource` (it has no effect on a caller-supplied source or when `disable_caching=True`). **`MCPSkillsSource` and `MCPSkill` accept exactly one of `client` (a fixed `ClientSession`) or `session_provider` (`Callable[[], ClientSession]`, resolved on every fetch); providing both/neither raises `ValueError`.** Use `session_provider` when the underlying session may be swapped over time — e.g. a reconnecting `MCPTool`/`FoundryToolbox` whose `session` is replaced on reconnect — so cached `MCPSkill`s keep fetching against the live session instead of a closed one (`MCPSkillsSource` forwards its provider to every `MCPSkill` it creates). A fixed `client` is safe only when the session outlives the skills. @@ -187,7 +189,7 @@ The vector store API is experimental under the shared `VECTOR_STORES` feature ID ### File Memory Harness (`_harness/_file_memory.py`) - **`FileMemoryProvider`** - `ContextProvider` that gives an agent a session-scoped, file-based memory backed by the same `AgentFileStore` abstraction. Adds tools (`file_memory_write`, `file_memory_read`, `file_memory_delete`, `file_memory_ls`, `file_memory_grep`, `file_memory_replace`, `file_memory_replace_lines`) plus default usage instructions. Port of the .NET `FileMemoryProvider`. -- **Scoping** - Memories are isolated per session by default: each session writes under a working folder derived from `context.session_id`. Pass an explicit `scope` (e.g. a user id) to group memories across sessions, mirroring `FoundryMemoryProvider`'s `scope` arg. +- **Scoping** - Memories are isolated per session by default: each session writes under a working folder derived from `context.session_id`. Pass an explicit `scope` (e.g. a user id) to group memories across sessions, mirroring `FoundryMemoryProvider`'s `scope` arg. The scope (or session id) is an **opaque namespace key, not a path**: it is mapped to exactly one folder via the shared `agent_framework._filesystem._storage_key_segment` derivation, which is **injective** (except for a collision-resistant digest fallback past a length cap) — two byte-distinct values do not share a working folder, so a caller authorized for one identifier cannot reach another's memories. A multi-segment value such as `"tenants/alice"` therefore becomes a single encoded folder rather than a nested directory. The provider **fails closed** (`ValueError`) when neither a `scope` nor a `session_id` is available, because an empty working folder would be the shared store root. Applications should still canonicalize and authorize externally supplied identifiers themselves; the storage mapping is the storage-layer guarantee, not a substitute for that check. - **Descriptions & index** - `file_memory_write` accepts an optional `description`, stored in a companion `_description.md` sidecar. After each write/delete the provider rebuilds a capped (50-entry) `memories.md` index, and `before_run` injects that index as a `user` context message so the model knows what memories exist. Sidecars and the index are internal files hidden from `file_memory_ls`/`file_memory_grep` and rejected as write targets. - **`DEFAULT_FILE_MEMORY_SOURCE_ID`** / **`DEFAULT_FILE_MEMORY_INSTRUCTIONS`** - Public defaults for the provider's source id and instruction banner. - **Harness wiring** - `create_harness_agent` includes the `FileMemoryProvider` by default; the `FileAccessProvider` is opt-in and added only when a `file_access_store` is supplied (no implicit `{cwd}/working` store is created). Disable file memory via `disable_file_memory`; override its backing store via `file_memory_store`. When no file-memory store is supplied, the default is `FileSystemAgentFileStore` rooted at `{cwd}/agent-file-memory`. `create_harness_agent` also wires in `MessageInjectionMiddleware` by default (mirroring the .NET harness's `UseMessageInjection`); it is always on with no opt-out because it is a no-op when no messages are queued for the session. diff --git a/python/packages/core/agent_framework/_filesystem.py b/python/packages/core/agent_framework/_filesystem.py index ac907621da5..512ca17ff7f 100644 --- a/python/packages/core/agent_framework/_filesystem.py +++ b/python/packages/core/agent_framework/_filesystem.py @@ -4,11 +4,47 @@ from __future__ import annotations +import hashlib import stat +from base64 import b32encode from pathlib import Path +_WINDOWS_RESERVED_FILE_STEMS: frozenset[str] = frozenset({ + "CON", + "PRN", + "AUX", + "NUL", + "COM1", + "COM2", + "COM3", + "COM4", + "COM5", + "COM6", + "COM7", + "COM8", + "COM9", + "LPT1", + "LPT2", + "LPT3", + "LPT4", + "LPT5", + "LPT6", + "LPT7", + "LPT8", + "LPT9", + "COM¹", + "COM²", + "COM³", + "LPT¹", + "LPT²", + "LPT³", +}) -def is_link_or_reparse_point(path: Path) -> bool: +_MAX_ENCODED_STORAGE_KEY_SEGMENT_LENGTH = 180 +_DIGEST_SEGMENT_MARKER = "sha256-" + + +def _is_link_or_reparse_point(path: Path) -> bool: # pyright: ignore[reportUnusedFunction] """Return whether ``path`` is a symbolic link, junction, or other reparse point.""" path_stat = path.lstat() if stat.S_ISLNK(path_stat.st_mode): @@ -21,3 +57,82 @@ def is_link_or_reparse_point(path: Path) -> bool: reparse_attribute = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) file_attributes = getattr(path_stat, "st_file_attributes", 0) return bool(reparse_attribute and file_attributes & reparse_attribute) + + +def _is_literal_storage_key_segment_safe(value: str) -> bool: + """Return whether an opaque identifier can be used verbatim as one path segment. + + The predicate is deliberately conservative because the result is used as a + security boundary (see :func:`_storage_key_segment`). Only *lowercase* ASCII + alphanumerics and ``.``/``_``/``-`` are accepted, so a literal segment can + never be folded onto a different identifier by the filesystem: + + * Rejecting non-ASCII avoids Unicode normalization folding (macOS APFS and + HFS+ map the NFD and NFC forms of the same text onto one directory entry). + * Rejecting uppercase avoids case folding (NTFS and APFS are case-insensitive + by default, so ``A`` and ``a`` would be one directory entry). Uppercase + values are encoded rather than lowercased, because lowercasing ``A`` would + collide with a genuine ``a``. + + Values that are not literal-safe are encoded rather than rejected. + """ + if ( + not value + or value.startswith(".") + or value.endswith((" ", ".")) + or value.split(".", maxsplit=1)[0].upper() in _WINDOWS_RESERVED_FILE_STEMS + ): + return False + if any(ord(character) < 32 for character in value): + return False + return all( + character.isascii() and ((character.isalnum() and not character.isupper()) or character in "._-") + for character in value + ) + + +def _storage_key_segment(value: str, *, encoded_prefix: str) -> str: # pyright: ignore[reportUnusedFunction] + """Return a filesystem-safe path segment for an opaque identifier. + + This is the single derivation used everywhere an identifier that participates + in an isolation boundary (a session ID, an owner ID, a memory scope) is turned + into a storage location. Callers must not pre-normalize the value: a general + path canonicalizer is lossy, and identifiers that differ only in separators or + surrounding whitespace would then share one storage namespace. + + The derivation has three branches: + + * Literal-safe values are returned verbatim, so on-disk layouts stay readable. + * Everything else is encoded under ``encoded_prefix`` using lowercase base32. + This branch is **injective**: base32 is exactly reversible, and its alphabet + (``a``-``z`` and ``2``-``7``) is case-stable, so the mapping stays one-to-one + even on a case-insensitive filesystem. The literal and encoded namespaces + cannot overlap because :func:`_is_literal_storage_key_segment_safe` rejects + every value starting with ``~``, which each ``encoded_prefix`` begins with. + * Past :data:`_MAX_ENCODED_STORAGE_KEY_SEGMENT_LENGTH` the encoding is replaced + by a SHA-256 digest so the segment stays within filesystem name limits. This + branch maps an unbounded input space onto 256 bits, so it is + **collision-resistant** rather than injective: distinct identifiers sharing a + namespace requires finding a SHA-256 collision. + + Args: + value: The opaque identifier to convert. + + Keyword Args: + encoded_prefix: Component-specific prefix applied to encoded values, so + that two components encoding the same identifier stay distinguishable. + Must start with ``~`` to stay outside the literal namespace. + + Returns: + A single path segment containing no path separators. + """ + if _is_literal_storage_key_segment_safe(value): + return value + encoded_value = b32encode(value.encode("utf-8")).decode("ascii").rstrip("=").lower() + encoded_segment = f"{encoded_prefix}{encoded_value}" + if len(encoded_segment) <= _MAX_ENCODED_STORAGE_KEY_SEGMENT_LENGTH: + return encoded_segment + # ``-`` is outside the base32 alphabet, so a digest segment can never collide + # with the encoding of some other (shorter) identifier. + digest = hashlib.sha256(value.encode("utf-8")).hexdigest() + return f"{encoded_prefix}{_DIGEST_SEGMENT_MARKER}{digest}" diff --git a/python/packages/core/agent_framework/_harness/_file_access.py b/python/packages/core/agent_framework/_harness/_file_access.py index a4f074ff0f7..7dc95779ebc 100644 --- a/python/packages/core/agent_framework/_harness/_file_access.py +++ b/python/packages/core/agent_framework/_harness/_file_access.py @@ -35,7 +35,7 @@ from pydantic import BaseModel, Field from .._feature_stage import ExperimentalFeature, experimental -from .._filesystem import is_link_or_reparse_point +from .._filesystem import _is_link_or_reparse_point # pyright: ignore[reportPrivateUsage] from .._serialization import SerializationMixin from .._sessions import AgentSession, ContextProvider, SessionContext from .._telemetry import FeatureIndex, mark_feature_used @@ -157,6 +157,14 @@ def _normalize_relative_path(path: str, *, is_directory: bool = False) -> str: trailing separators are not accepted (so ``"foo/"`` does not silently become the file path ``"foo"``). + This is for *paths supplied as tool arguments*. It is deliberately lossy: + ``"a/"`` and ``"a//"`` both normalize to ``"a"``, as does the equivalent + backslash-terminated spelling. Never use it to derive a storage namespace + from an identifier that participates in an isolation boundary (a session ID, + owner ID, or memory scope) — distinct identifiers would share one location. + Use :func:`~agent_framework._filesystem._storage_key_segment` for that + instead. + Args: path: The relative path to normalize. @@ -1160,7 +1168,7 @@ def _resolve_safe_directory_path(self, relative_directory: str) -> Path: # normal and must stay cheap, but a dangling link still has to be rejected rather than # read as absent. if os.path.lexists(self._root_path): - if is_link_or_reparse_point(self._root_path): + if _is_link_or_reparse_point(self._root_path): raise ValueError("Invalid path: the resolved path contains a symbolic link or reparse point.") if self._root_path.resolve() != self._root_path: raise ValueError("Invalid path: the resolved path escapes the root directory.") @@ -1187,7 +1195,7 @@ def _throw_if_contains_symlink(self, candidate: Path) -> None: for segment in relative_parts: current = current / segment try: - is_link = is_link_or_reparse_point(current) + is_link = _is_link_or_reparse_point(current) except FileNotFoundError: break except OSError as exc: @@ -1301,7 +1309,7 @@ def _list_sync(full_dir: Path) -> list[FileStoreEntry]: files: list[FileStoreEntry] = [] for entry in full_dir.iterdir(): try: - is_link = is_link_or_reparse_point(entry) + is_link = _is_link_or_reparse_point(entry) except OSError: # Fail closed when an entry cannot be inspected. continue @@ -1362,7 +1370,7 @@ def _enumerate_search_files(full_dir: Path, recursive: bool) -> list[tuple[str, current = directories.pop() for entry in current.iterdir(): try: - is_link = is_link_or_reparse_point(entry) + is_link = _is_link_or_reparse_point(entry) except OSError: # Fail closed when an entry cannot be inspected. continue @@ -1392,7 +1400,7 @@ def _search_files_sync( # Re-checked here, not only during enumeration: a candidate can be swapped for a # link in between. O_NOFOLLOW makes the read itself atomic where the platform has # it, and this narrows the window on Windows, where it does not exist. - if is_link_or_reparse_point(entry): + if _is_link_or_reparse_point(entry): logger.warning("Skipping symlinked file during search: %s", entry) skipped.append(relative_name) continue diff --git a/python/packages/core/agent_framework/_harness/_file_memory.py b/python/packages/core/agent_framework/_harness/_file_memory.py index 1dff633906c..bed59710b74 100644 --- a/python/packages/core/agent_framework/_harness/_file_memory.py +++ b/python/packages/core/agent_framework/_harness/_file_memory.py @@ -19,6 +19,14 @@ folder (derived from the session id). Pass an explicit ``scope`` to group memories differently, for example by user id. +A ``scope`` (and the session id it defaults to) is treated as an **opaque +namespace key**, not as a path: it is mapped to exactly one storage folder by a +derivation that is injective for all but pathologically long values, so two +byte-distinct values cannot share a working folder. Applications that derive a +scope from an externally supplied value should still canonicalize and authorize +that value themselves; the mapping here is the storage-layer guarantee, not a +substitute for that check. + The provider exposes the following tools to the agent (registered on the per-invocation :class:`~agent_framework.SessionContext` in :meth:`FileMemoryProvider.before_run`): @@ -40,6 +48,7 @@ from pydantic import BaseModel, Field +from .._filesystem import _storage_key_segment # pyright: ignore[reportPrivateUsage] from .._sessions import AgentSession, ContextProvider, SessionContext from .._tools import tool from .._types import Message @@ -77,6 +86,10 @@ _DESCRIPTION_SUFFIX = "_description.md" _MEMORY_INDEX_FILE_NAME = "memories.md" _MAX_INDEX_ENTRIES = 50 +# Prefix for scopes that are not usable verbatim as a folder name. Kept distinct +# from the other harness prefixes so the same value under a different component +# never lands in the same directory. +_ENCODED_SCOPE_PREFIX = "~scope-" def _description_file_name(file_name: str) -> str: @@ -247,6 +260,11 @@ class FileMemoryProvider(ContextProvider): Memories are isolated per session: each session reads and writes under a working folder derived from its session id. Pass an explicit ``scope`` to group memories differently (for example, per user id) across sessions. + + The scope (or session id) is an opaque namespace key, not a path. It is + mapped to a single folder by a derivation that is injective for all but + pathologically long values, so two byte-distinct values cannot share a + working folder. """ def __init__( @@ -267,7 +285,11 @@ def __init__( scope: The namespace that logically groups and isolates memories (for example, a user ID). Used as the working folder within the store. When ``None`` (the default), the active session's - ``session_id`` is used, isolating memories per session. + ``session_id`` is used, isolating memories per session. The + value is treated as an opaque key rather than a path: it is + mapped onto exactly one folder, so a multi-segment value + such as ``"tenants/alice"`` becomes a single encoded folder + instead of a nested directory. instructions: Optional instruction override. When ``None`` the default file-memory instructions are used. """ @@ -284,11 +306,26 @@ def _resolve_working_folder(self, context: SessionContext) -> str: """Resolve the working folder for the current invocation. Uses the configured ``scope`` when set, otherwise the session id. The - result is normalized as a relative directory path so it cannot escape - the store root. + value is an opaque namespace key, not a path: it is mapped to exactly + one folder name by :func:`~agent_framework._filesystem._storage_key_segment`. + That derivation is injective except for pathologically long values, + which fall back to a collision-resistant digest. Two byte-distinct + scopes or session ids therefore do not resolve to the same working + folder, so a caller authorized for one of them cannot reach another's + memories. + + Raises: + ValueError: When neither ``scope`` nor the session id yields a + namespace. Without one there is nothing to isolate on, and + falling back to the store root would expose every other scope. """ raw_scope = self.scope or context.session_id or "" - return _normalize_relative_path(raw_scope, is_directory=True) + if not raw_scope: + raise ValueError( + "FileMemoryProvider requires a memory scope: pass an explicit 'scope' or run with a session " + "that has a 'session_id'. Without one, memories cannot be isolated from other scopes." + ) + return _storage_key_segment(raw_scope, encoded_prefix=_ENCODED_SCOPE_PREFIX) async def _rebuild_index(self, working_folder: str) -> None: """Rebuild the ``memories.md`` index for ``working_folder``. diff --git a/python/packages/core/agent_framework/_harness/_memory.py b/python/packages/core/agent_framework/_harness/_memory.py index 2c43cb70158..66163abed05 100644 --- a/python/packages/core/agent_framework/_harness/_memory.py +++ b/python/packages/core/agent_framework/_harness/_memory.py @@ -10,7 +10,8 @@ import threading import weakref from abc import ABC, abstractmethod -from base64 import urlsafe_b64decode, urlsafe_b64encode +from base64 import b32decode +from binascii import Error as BinasciiError from collections.abc import Callable, Mapping, Sequence from datetime import datetime, timedelta, timezone from pathlib import Path @@ -19,6 +20,7 @@ from .._clients import SupportsChatGetResponse from .._compaction import group_messages from .._feature_stage import ExperimentalFeature, experimental +from .._filesystem import _storage_key_segment # pyright: ignore[reportPrivateUsage] from .._sessions import AgentSession, FileHistoryProvider, HistoryProvider, JsonDumps, JsonLoads, SessionContext from .._telemetry import FeatureIndex, mark_feature_used from .._tools import tool @@ -648,7 +650,13 @@ def search_transcripts( session_id: str | None = None, limit: int = 20, ) -> list[dict[str, Any]]: - """Search the raw transcript archive for matching text snippets.""" + """Search the raw transcript archive for matching text snippets. + + When ``session_id`` is given, only transcripts belonging to that session are searched, and + every returned row reports that same ``session_id``. Otherwise all transcripts are searched + and each row reports the session the transcript could be attributed to, or ``None`` when the + implementation cannot determine it. + """ @experimental(feature_id=ExperimentalFeature.HARNESS) @@ -731,10 +739,18 @@ def import_provider_state(self, session: AgentSession, *, state: Mapping[str, An ) session.state[self.owner_state_key] = owner_value - @staticmethod - def _encode_path_component(value: str) -> str: - encoded_value = urlsafe_b64encode(value.encode("utf-8")).decode("ascii").rstrip("=") - return encoded_value or "_" + _ENCODED_SEGMENT_PREFIX: ClassVar[str] = "~memory-" + + @classmethod + def _encode_path_component(cls, value: str) -> str: + """Return a filesystem-safe path segment for an owner ID or source ID. + + Delegates to the shared + :func:`~agent_framework._filesystem._storage_key_segment` derivation, so + two byte-distinct values do not share a directory (values past a + length cap fall back to a collision-resistant digest). + """ + return _storage_key_segment(value, encoded_prefix=cls._ENCODED_SEGMENT_PREFIX) def _get_memory_root(self, session: AgentSession, *, source_id: str) -> Path: owner_component = self._encode_path_component(f"{self.owner_prefix}{self._get_owner_id(session)}") @@ -773,8 +789,26 @@ def _decode_transcript_session_id(file_path: Path) -> str | None: if not file_stem.startswith(_FILE_HISTORY_ENCODED_SESSION_PREFIX): return file_stem encoded_value = file_stem[len(_FILE_HISTORY_ENCODED_SESSION_PREFIX) :] - padded_value = encoded_value + ("=" * (-len(encoded_value) % 4)) - return urlsafe_b64decode(padded_value.encode("ascii")).decode("utf-8") + padded_value = encoded_value + ("=" * (-len(encoded_value) % 8)) + try: + return b32decode(padded_value.encode("ascii"), casefold=True).decode("utf-8") + except (BinasciiError, UnicodeDecodeError, ValueError): + # Very long session IDs are stored under an irreversible digest stem, + # and unrelated files may share the prefix. Neither maps back to a + # session ID, so treat the transcript as unattributed rather than + # failing the whole scan. + return None + + @staticmethod + def _transcript_file_stem(session_id: str) -> str: + """Return the transcript file stem that ``FileHistoryProvider`` writes for ``session_id``. + + This is the forward counterpart to :meth:`_decode_transcript_session_id`. Deriving the stem + works for every session ID, including the very long ones stored under an irreversible digest + stem that cannot be decoded back to a session ID. + """ + raw_session_id = session_id or FileHistoryProvider.DEFAULT_SESSION_FILE_STEM + return _storage_key_segment(raw_session_id, encoded_prefix=_FILE_HISTORY_ENCODED_SESSION_PREFIX) def list_topics(self, session: AgentSession, *, source_id: str) -> list[MemoryTopicRecord]: """Return all topic memory files visible from the current owner.""" @@ -890,7 +924,13 @@ def search_transcripts( session_id: str | None = None, limit: int = 20, ) -> list[dict[str, Any]]: - """Search the raw transcript archive for matching text snippets.""" + """Search the raw transcript archive for matching text snippets. + + When ``session_id`` is given, transcripts are selected by deriving the file stem that + :class:`FileHistoryProvider` writes for that session ID and comparing it to each file's stem. + Matching forward this way keeps transcripts reachable even when their stem is an irreversible + digest, which reverse-decoding cannot recover. + """ normalized_query = query.strip() if not normalized_query: raise ValueError("query must not be empty.") @@ -898,12 +938,17 @@ def search_transcripts( transcripts_directory = self.get_transcripts_directory(session, source_id=source_id) if not transcripts_directory.exists(): return [] + expected_stem = None if session_id is None else self._transcript_file_stem(session_id) transcript_files = sorted(transcripts_directory.glob("*.jsonl")) results: list[dict[str, Any]] = [] for transcript_file in transcript_files: - decoded_session_id = self._decode_transcript_session_id(transcript_file) - if session_id is not None and decoded_session_id != session_id: - continue + if expected_stem is not None: + if transcript_file.stem != expected_stem: + continue + # Report what the caller asked for: decoding a digest stem yields ``None``. + matched_session_id = session_id + else: + matched_session_id = self._decode_transcript_session_id(transcript_file) with transcript_file.open(encoding="utf-8") as file_handle: for line_number, line in enumerate(file_handle, start=1): serialized = line.strip() @@ -917,7 +962,7 @@ def search_transcripts( if not text or query_casefold not in text.casefold(): continue results.append({ - "session_id": decoded_session_id, + "session_id": matched_session_id, "line_number": line_number, "role": message.role, "text": text, diff --git a/python/packages/core/agent_framework/_harness/_todo.py b/python/packages/core/agent_framework/_harness/_todo.py index 1c675097326..daa554a585e 100644 --- a/python/packages/core/agent_framework/_harness/_todo.py +++ b/python/packages/core/agent_framework/_harness/_todo.py @@ -7,7 +7,6 @@ import os import weakref from abc import ABC, abstractmethod -from base64 import urlsafe_b64encode from collections.abc import Mapping, MutableMapping from pathlib import Path from typing import Any, ClassVar, cast @@ -15,6 +14,10 @@ from typing_extensions import NotRequired, TypedDict from .._feature_stage import ExperimentalFeature, experimental +from .._filesystem import ( + _is_literal_storage_key_segment_safe, # pyright: ignore[reportPrivateUsage] + _storage_key_segment, # pyright: ignore[reportPrivateUsage] +) from .._serialization import SerializationMixin from .._sessions import AgentSession, ContextProvider, SessionContext from .._telemetry import FeatureIndex, mark_feature_used @@ -316,30 +319,6 @@ def __init__( self._base_root = self.base_path.resolve() _ENCODED_SEGMENT_PREFIX: ClassVar[str] = "~todo-" - _WINDOWS_RESERVED_FILE_STEMS: ClassVar[frozenset[str]] = frozenset({ - "CON", - "PRN", - "AUX", - "NUL", - "COM1", - "COM2", - "COM3", - "COM4", - "COM5", - "COM6", - "COM7", - "COM8", - "COM9", - "LPT1", - "LPT2", - "LPT3", - "LPT4", - "LPT5", - "LPT6", - "LPT7", - "LPT8", - "LPT9", - }) def _get_state_path(self, session: AgentSession, *, source_id: str) -> Path: """Return the JSON file path for one session and source ID.""" @@ -362,28 +341,22 @@ def _get_state_path(self, session: AgentSession, *, source_id: str) -> Path: @classmethod def _path_segment(cls, value: object, *, label: str, reject_path_separators: bool = False) -> str: - """Return a filesystem-safe path segment for user-controlled state values.""" + """Return a filesystem-safe path segment for user-controlled state values. + + Delegates to the shared + :func:`~agent_framework._filesystem._storage_key_segment` derivation, so + two byte-distinct values do not share a directory (values past a + length cap fall back to a collision-resistant digest). + """ raw_value = str(value) if reject_path_separators and ("/" in raw_value or "\\" in raw_value): raise ValueError(f"TodoFileStore {label} must not contain path separators: {raw_value!r}") - if cls._is_literal_path_segment_safe(raw_value): - return raw_value - encoded_value = urlsafe_b64encode(raw_value.encode("utf-8")).decode("ascii").rstrip("=") - return f"{cls._ENCODED_SEGMENT_PREFIX}{encoded_value or label}" + return _storage_key_segment(raw_value, encoded_prefix=cls._ENCODED_SEGMENT_PREFIX) @classmethod def _is_literal_path_segment_safe(cls, value: str) -> bool: """Return whether a value can be used directly as one path segment.""" - if ( - not value - or value.startswith(".") - or value.endswith((" ", ".")) - or value.upper() in cls._WINDOWS_RESERVED_FILE_STEMS - ): - return False - if any(ord(character) < 32 for character in value): - return False - return all(character.isalnum() or character in "._-" for character in value) + return _is_literal_storage_key_segment_safe(value) def _state_filename(self, source_id: str) -> str: """Return a source-specific JSON state filename.""" diff --git a/python/packages/core/agent_framework/_sessions.py b/python/packages/core/agent_framework/_sessions.py index 27e313b8e6e..c11a8ae9c28 100644 --- a/python/packages/core/agent_framework/_sessions.py +++ b/python/packages/core/agent_framework/_sessions.py @@ -18,7 +18,6 @@ import asyncio import contextlib import copy -import hashlib import json import logging import math @@ -28,7 +27,6 @@ import warnings import weakref from abc import abstractmethod -from base64 import urlsafe_b64encode from collections import deque from collections.abc import AsyncIterable, Awaitable, Callable, Generator, Iterable, Mapping, Sequence from contextvars import ContextVar, Token @@ -40,6 +38,10 @@ import msgspec from ._feature_stage import ExperimentalFeature, experimental +from ._filesystem import ( + _is_literal_storage_key_segment_safe, # pyright: ignore[reportPrivateUsage] + _storage_key_segment, # pyright: ignore[reportPrivateUsage] +) from ._middleware import ChatContext, ChatMiddleware from ._telemetry import FeatureIndex, mark_feature_used from ._types import ( @@ -73,36 +75,6 @@ StateEncoder: TypeAlias = Callable[[Any], Mapping[str, Any]] StateDecoder: TypeAlias = Callable[[Mapping[str, Any]], Any] _STATE_SCALAR_TYPES = (str, int, float, bool, type(None)) -_WINDOWS_RESERVED_FILE_STEMS: frozenset[str] = frozenset({ - "CON", - "PRN", - "AUX", - "NUL", - "COM1", - "COM2", - "COM3", - "COM4", - "COM5", - "COM6", - "COM7", - "COM8", - "COM9", - "LPT1", - "LPT2", - "LPT3", - "LPT4", - "LPT5", - "LPT6", - "LPT7", - "LPT8", - "LPT9", - "COM¹", - "COM²", - "COM³", - "LPT¹", - "LPT²", - "LPT³", -}) _DEFAULT_JSON_ENCODER = msgspec.json.Encoder() @@ -113,7 +85,6 @@ _JSON_LINES_FILE_EXTENSION = ".jsonl" _MSGPACK_FILE_EXTENSION = ".msgpack" _SESSION_SNAPSHOT_VERSION = "1.0" -_MAX_ENCODED_SESSION_FILE_STEM_LENGTH = 180 def _default_json_dumps(value: Any) -> bytes: @@ -152,29 +123,17 @@ def _is_literal_session_file_stem_safe(session_id: str) -> bool: with separators and platform-reserved names such as ``CON``. Unsafe values are encoded by :func:`_session_file_stem` rather than rejected. """ - windows_stem = session_id.split(".", maxsplit=1)[0].upper() - if ( - not session_id - or session_id.startswith(".") - or session_id.endswith((" ", ".")) - or windows_stem in _WINDOWS_RESERVED_FILE_STEMS - ): - return False - if any(ord(character) < 32 for character in session_id): - return False - return all(character.isascii() and (character.isalnum() or character in "._-") for character in session_id) + return _is_literal_storage_key_segment_safe(session_id) def _session_file_stem(session_id: str, *, encoded_prefix: str) -> str: - """Return a safe filename stem for an opaque session ID.""" - if _is_literal_session_file_stem_safe(session_id): - return session_id - encoded_session_id = urlsafe_b64encode(session_id.encode("utf-8")).decode("ascii").rstrip("=") - encoded_stem = f"{encoded_prefix}{encoded_session_id}" - if len(encoded_stem) <= _MAX_ENCODED_SESSION_FILE_STEM_LENGTH: - return encoded_stem - digest = hashlib.sha256(session_id.encode("utf-8")).hexdigest() - return f"{encoded_prefix}sha256-{digest}" + """Return a safe filename stem for an opaque session ID. + + Delegates to the shared :func:`~agent_framework._filesystem._storage_key_segment` + derivation so every component that maps an identifier onto storage produces + the same injective result. + """ + return _storage_key_segment(session_id, encoded_prefix=encoded_prefix) def _deduplicate_origin_session_ids(origin_session_ids: Iterable[str]) -> list[str]: diff --git a/python/packages/core/agent_framework/_skills.py b/python/packages/core/agent_framework/_skills.py index d16d5616ea9..e118464438a 100644 --- a/python/packages/core/agent_framework/_skills.py +++ b/python/packages/core/agent_framework/_skills.py @@ -66,7 +66,7 @@ from typing import IO, TYPE_CHECKING, Any, ClassVar, Final, Protocol, TypeAlias, TypeVar, cast, runtime_checkable from ._feature_stage import ExperimentalFeature, experimental -from ._filesystem import is_link_or_reparse_point +from ._filesystem import _is_link_or_reparse_point # pyright: ignore[reportPrivateUsage] from ._sessions import ContextProvider from ._telemetry import FeatureIndex, mark_feature_used from ._tools import ApprovalMode, FunctionTool @@ -3022,7 +3022,7 @@ def _has_link_or_reparse_point_in_path(path: str, directory: str) -> bool: for part in relative.parts: current = current / part try: - is_link = is_link_or_reparse_point(current) + is_link = _is_link_or_reparse_point(current) except OSError: return True if is_link: @@ -3569,7 +3569,7 @@ def _discover_skill_directories(skill_paths: Sequence[str]) -> list[str]: def _is_unsafe_link(path: Path) -> bool: try: - return is_link_or_reparse_point(path) + return _is_link_or_reparse_point(path) except OSError: return True diff --git a/python/packages/core/tests/core/test_filesystem.py b/python/packages/core/tests/core/test_filesystem.py new file mode 100644 index 00000000000..764f8307aac --- /dev/null +++ b/python/packages/core/tests/core/test_filesystem.py @@ -0,0 +1,229 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for the shared storage-key derivation in ``agent_framework._filesystem``. + +Every component that maps a caller-controlled identifier (a session ID, an owner +ID, a memory scope) onto a storage location routes through +:func:`_storage_key_segment`. Its defining property is **injectivity**: two +byte-distinct identifiers must not produce the same segment, because the segment +is an isolation boundary. Only the digest fallback for pathologically long +values weakens this to collision resistance. + +Injectivity is asserted **case-insensitively** throughout, because NTFS and APFS +are case-insensitive by default: two segments that differ only in case are one +directory entry there, which would reintroduce the collision this module exists +to prevent. +""" + +from __future__ import annotations + +import pytest + +from agent_framework._filesystem import ( # pyright: ignore[reportPrivateUsage] + _MAX_ENCODED_STORAGE_KEY_SEGMENT_LENGTH, + _is_literal_storage_key_segment_safe, + _storage_key_segment, +) + +# Identifiers that a lossy path normalizer -- or a case-insensitive filesystem -- +# would fold together. Shared with the per-provider parity tests so every +# component is held to the same contract. +COLLIDING_IDENTIFIERS: tuple[str, ...] = ( + "customer-42", + "customer-42/", + "customer-42//", + "customer-42\\", + "customer-42\\\\", + "/customer-42", + "//customer-42", + " customer-42", + "customer-42 ", + " customer-42 ", + "\tcustomer-42", + "customer-42/.", + "./customer-42", + "customer-42/..", + # Case variants: distinct identifiers that a case-insensitive filesystem + # folds onto one directory entry unless they are encoded apart. + "Customer-42", + "CUSTOMER-42", + "cUsToMeR-42", + "a", + "A", +) + +UNSAFE_IDENTIFIERS: tuple[str, ...] = ( + "", + ".", + "..", + ".hidden", + "trailing.", + "trailing ", + "CON", + "con", + "CON.txt", + "NUL.log", + "LPT1", + "COM\u00b9", + "with\nnewline", + "with\x00null", + "caf\u00e9", # NFC + "cafe\u0301", # NFD - byte-distinct from the NFC form above + "\u65e5\u672c\u8a9e", + "a/b", + "a\\b", + "a:b", + "a*b", + "a b", + "UID1", # uppercase is encoded, never lowercased + "Session-1", + "0198F0C5-1A2B-7C3D-8E4F-5A6B7C8D9E0F", +) + +SAFE_IDENTIFIERS: tuple[str, ...] = ( + "customer-42", + "customer_42", + "customer.42", + "0", + "a", + "0198f0c5-1a2b-7c3d-8e4f-5a6b7c8d9e0f", + "session-1", + "console", + "cont", + "x" * 200, +) + + +def test_safe_identifiers_are_used_verbatim() -> None: + """An already-safe identifier keeps its literal folder name. + + This is what keeps typical deployments (lowercase UUIDs, slugs) from needing + a data migration. + """ + for value in SAFE_IDENTIFIERS: + assert _is_literal_storage_key_segment_safe(value) + assert _storage_key_segment(value, encoded_prefix="~scope-") == value + + +def test_unsafe_identifiers_are_encoded_not_rejected() -> None: + for value in UNSAFE_IDENTIFIERS: + assert not _is_literal_storage_key_segment_safe(value) + segment = _storage_key_segment(value, encoded_prefix="~scope-") + assert segment.startswith("~scope-") + + +def test_derivation_is_injective_over_colliding_identifiers() -> None: + """The MSRC case: values a path normalizer folds together stay distinct.""" + segments = {value: _storage_key_segment(value, encoded_prefix="~scope-") for value in COLLIDING_IDENTIFIERS} + assert len(set(segments.values())) == len(COLLIDING_IDENTIFIERS), segments + + +def test_derivation_is_injective_over_every_known_identifier() -> None: + all_values = (*COLLIDING_IDENTIFIERS, *UNSAFE_IDENTIFIERS, *SAFE_IDENTIFIERS) + unique_values = set(all_values) + segments = {_storage_key_segment(value, encoded_prefix="~scope-") for value in unique_values} + assert len(segments) == len(unique_values) + + +def test_derivation_is_injective_on_case_insensitive_filesystems() -> None: + """Distinct identifiers must not fold together on NTFS or APFS. + + Comparing case-folded segments is the same check the filesystem performs + when deciding whether two names are one directory entry. + """ + all_values = (*COLLIDING_IDENTIFIERS, *UNSAFE_IDENTIFIERS, *SAFE_IDENTIFIERS) + unique_values = set(all_values) + folded = {_storage_key_segment(value, encoded_prefix="~scope-").lower() for value in unique_values} + assert len(folded) == len(unique_values) + + +def test_uppercase_identifiers_are_encoded_rather_than_lowercased() -> None: + """``A`` must not be stored as ``a``: that is a genuine other identifier.""" + assert _storage_key_segment("a", encoded_prefix="~scope-") == "a" + upper = _storage_key_segment("A", encoded_prefix="~scope-") + assert upper.startswith("~scope-") + assert upper.lower() != "a" + + +def test_encoded_segments_use_a_case_stable_alphabet() -> None: + """Lowercase base32 (``a``-``z``, ``2``-``7``) survives case folding intact.""" + alphabet = set("abcdefghijklmnopqrstuvwxyz234567") + for value in (*COLLIDING_IDENTIFIERS, *UNSAFE_IDENTIFIERS): + segment = _storage_key_segment(value, encoded_prefix="~scope-") + if not segment.startswith("~scope-"): + continue + body = segment.removeprefix("~scope-") + assert set(body) <= alphabet, (value, segment) + assert body == body.lower() + + +def test_unicode_normalization_forms_do_not_collide() -> None: + """NFC and NFD spellings are byte-distinct and must stay distinct. + + A literal non-ASCII folder name would be folded onto one directory entry by + macOS APFS/HFS+, so non-ASCII values are always encoded. + """ + nfc = _storage_key_segment("caf\u00e9", encoded_prefix="~scope-") + nfd = _storage_key_segment("cafe\u0301", encoded_prefix="~scope-") + assert nfc != nfd + + +def test_segments_never_contain_path_separators() -> None: + for value in (*COLLIDING_IDENTIFIERS, *UNSAFE_IDENTIFIERS, *SAFE_IDENTIFIERS): + segment = _storage_key_segment(value, encoded_prefix="~scope-") + assert "/" not in segment + assert "\\" not in segment + + +def test_literal_namespace_cannot_collide_with_encoded_namespace() -> None: + """No literal-safe value can look like an encoded segment. + + ``~`` is outside the literal charset, so an attacker cannot pick a plain + identifier that lands on some other identifier's encoded folder. + """ + assert not _is_literal_storage_key_segment_safe("~scope-mn2xg5dpnvsxeljugixq") + literal = _storage_key_segment("customer-42", encoded_prefix="~scope-") + encoded = _storage_key_segment("customer-42/", encoded_prefix="~scope-") + assert not literal.startswith("~") + assert encoded.startswith("~") + + +def test_prefix_separates_components_for_the_same_identifier() -> None: + """Two components encoding the same value stay in distinct folders.""" + value = "customer-42/" + assert _storage_key_segment(value, encoded_prefix="~scope-") != _storage_key_segment(value, encoded_prefix="~todo-") + + +def test_long_identifiers_fall_back_to_a_digest_segment() -> None: + long_value = "customer-42/" + "x" * 400 + segment = _storage_key_segment(long_value, encoded_prefix="~scope-") + assert segment.startswith("~scope-sha256-") + assert len(segment) <= _MAX_ENCODED_STORAGE_KEY_SEGMENT_LENGTH + + +def test_digest_segments_are_collision_resistant() -> None: + """The digest branch is collision-resistant, not injective. + + SHA-256 maps an unbounded input space onto 256 bits, so distinct long + identifiers sharing a namespace requires finding a SHA-256 collision. + """ + first = _storage_key_segment("a" * 400, encoded_prefix="~scope-") + second = _storage_key_segment("a" * 401, encoded_prefix="~scope-") + assert first != second + + +def test_digest_marker_is_outside_the_encoded_alphabet() -> None: + """A base32 encoding can never be mistaken for a digest segment. + + ``-`` is not a base32 character, so the two encoded forms occupy disjoint + namespaces even though they share a prefix. + """ + encoded = _storage_key_segment("customer-42/", encoded_prefix="~scope-") + assert "-" not in encoded.removeprefix("~scope-") + + +@pytest.mark.parametrize("value", ["customer-42", "customer-42/", "caf\u00e9", "a" * 400]) +def test_derivation_is_deterministic(value: str) -> None: + assert _storage_key_segment(value, encoded_prefix="~scope-") == _storage_key_segment( + value, encoded_prefix="~scope-" + ) diff --git a/python/packages/core/tests/core/test_harness_file_access.py b/python/packages/core/tests/core/test_harness_file_access.py index 2789f438228..da7b4255f40 100644 --- a/python/packages/core/tests/core/test_harness_file_access.py +++ b/python/packages/core/tests/core/test_harness_file_access.py @@ -29,7 +29,7 @@ Message, SupportsChatGetResponse, ) -from agent_framework._filesystem import is_link_or_reparse_point +from agent_framework._filesystem import _is_link_or_reparse_point from agent_framework._harness import _file_access as _file_access_module from agent_framework._harness._file_access import ( _SEARCH_SNIPPET_RADIUS, @@ -335,7 +335,12 @@ async def test_filesystem_search_does_not_read_through_a_symlink( "_enumerate_search_files", staticmethod(lambda full_dir, recursive: [("notes.txt", swapped)]), ) - monkeypatch.setattr(_file_access_module, "is_link_or_reparse_point", lambda candidate: candidate == swapped) + monkeypatch.setattr( + _file_access_module, + "_is_link_or_reparse_point", + lambda candidate: candidate == swapped, + raising=True, + ) store = FileSystemAgentFileStore(root) results = await store.search("", "needle", recursive=True) @@ -957,7 +962,7 @@ def fake_lstat(self: Path) -> SimpleNamespace: monkeypatch.setattr(Path, "lstat", fake_lstat) - assert is_link_or_reparse_point(path) is True + assert _is_link_or_reparse_point(path) is True def test_file_access_harness_classes_are_marked_experimental() -> None: diff --git a/python/packages/core/tests/core/test_harness_file_memory.py b/python/packages/core/tests/core/test_harness_file_memory.py index aa416ad3cf1..1442b151890 100644 --- a/python/packages/core/tests/core/test_harness_file_memory.py +++ b/python/packages/core/tests/core/test_harness_file_memory.py @@ -26,6 +26,8 @@ ) from agent_framework._sessions import SessionContext +from .test_filesystem import COLLIDING_IDENTIFIERS + def _tool_by_name(tools: list[object], name: str) -> FunctionTool: """Return the tool with the requested name from a prepared tool list.""" @@ -481,6 +483,145 @@ async def current() -> str: assert "Duplicate" in _text(dup) +# region Session isolation (MSRC): the working folder derivation must be injective + + +async def test_colliding_session_ids_resolve_to_distinct_working_folders() -> None: + """Session IDs that a path normalizer folds together must stay separate. + + The provider previously ran the session ID through a lossy path normalizer, + so ``"customer-42"`` and ``"customer-42/"`` shared one working folder. An + application that treats the session ID as part of its authorization boundary + would then have that decision silently undone by the storage layer. + """ + provider = FileMemoryProvider(store=InMemoryAgentFileStore()) + folders = { + session_id: provider._resolve_working_folder( # pyright: ignore[reportPrivateUsage] + SessionContext(session_id=session_id, input_messages=[]) + ) + for session_id in COLLIDING_IDENTIFIERS + } + assert len(set(folders.values())) == len(COLLIDING_IDENTIFIERS), folders + # Case-insensitive too: NTFS and APFS fold names that differ only in case. + assert len({folder.lower() for folder in folders.values()}) == len(COLLIDING_IDENTIFIERS), folders + + +async def test_colliding_scopes_resolve_to_distinct_working_folders() -> None: + """An explicitly configured scope gets the same injectivity guarantee.""" + folders = { + scope: FileMemoryProvider(store=InMemoryAgentFileStore(), scope=scope)._resolve_working_folder( # pyright: ignore[reportPrivateUsage] + SessionContext(session_id="session-1", input_messages=[]) + ) + for scope in COLLIDING_IDENTIFIERS + } + assert len(set(folders.values())) == len(COLLIDING_IDENTIFIERS), folders + assert len({folder.lower() for folder in folders.values()}) == len(COLLIDING_IDENTIFIERS), folders + + +async def test_safe_session_id_keeps_its_literal_working_folder() -> None: + """Canonical IDs are unchanged, so existing stores need no migration.""" + provider = FileMemoryProvider(store=InMemoryAgentFileStore()) + folder = provider._resolve_working_folder( # pyright: ignore[reportPrivateUsage] + SessionContext(session_id="customer-42", input_messages=[]) + ) + assert folder == "customer-42" + + +async def test_working_folder_is_never_the_store_root() -> None: + """Without a scope or session ID the provider fails closed. + + An empty working folder is the store root, from which every other scope's + directory is visible and writable. + """ + provider = FileMemoryProvider(store=InMemoryAgentFileStore()) + with pytest.raises(ValueError, match="requires a memory scope"): + provider._resolve_working_folder( # pyright: ignore[reportPrivateUsage] + SessionContext(session_id=None, input_messages=[]) + ) + with pytest.raises(ValueError, match="requires a memory scope"): + provider._resolve_working_folder( # pyright: ignore[reportPrivateUsage] + SessionContext(session_id="", input_messages=[]) + ) + + +async def test_before_run_fails_closed_without_a_scope() -> None: + """The failure surfaces through the public provider entry point too.""" + provider = FileMemoryProvider(store=InMemoryAgentFileStore()) + with pytest.raises(ValueError, match="requires a memory scope"): + await provider.before_run( + agent=None, + session=AgentSession(session_id="ignored"), + context=SessionContext(session_id=None, input_messages=[]), + state={}, + ) + + +@pytest.mark.parametrize( + "attacker_session_id", + ["customer-42/", "customer-42//", "customer-42\\", " customer-42 ", "customer-42/."], +) +async def test_colliding_session_cannot_read_write_or_delete_victim_memory(attacker_session_id: str) -> None: + """End-to-end: every public memory tool stays scoped to its own session. + + Mirrors the reported attack: the victim owns ``customer-42`` and the + attacker is authorized only for a normalization variant of it. + """ + store = InMemoryAgentFileStore() + provider = FileMemoryProvider(store=store) + + _, victim = await _prepare(provider, session_id="customer-42") + await victim["file_memory_write"].invoke( + arguments={"file_name": "secret.md", "content": "VICTIM_TOKEN=demo-secret"} + ) + + _, attacker = await _prepare(provider, session_id=attacker_session_id) + + # Discovery surfaces must not reveal the victim's memory. + assert json.loads(_text(await attacker["file_memory_ls"].invoke())) == [] + assert json.loads(_text(await attacker["file_memory_grep"].invoke(arguments={"regex_pattern": "VICTIM"}))) == [] + + # Read must not return the victim's content. + read_result = _text(await attacker["file_memory_read"].invoke(arguments={"file_name": "secret.md"})) + assert "demo-secret" not in read_result + + # Mutations must not reach the victim's file. + await attacker["file_memory_write"].invoke( + arguments={"file_name": "secret.md", "content": "ATTACKER_REPLACED_MEMORY"} + ) + await attacker["file_memory_replace"].invoke( + arguments={"file_name": "secret.md", "old_string": "ATTACKER", "new_string": "OVERWRITTEN"} + ) + await attacker["file_memory_replace_lines"].invoke( + arguments={"file_name": "secret.md", "edits": [{"line_number": 1, "new_line": "CLOBBERED\n"}]} + ) + await attacker["file_memory_delete"].invoke(arguments={"file_name": "secret.md"}) + + # The victim's memory is intact and unchanged. + _, victim_after = await _prepare(provider, session_id="customer-42") + assert [e["name"] for e in json.loads(_text(await victim_after["file_memory_ls"].invoke()))] == ["secret.md"] + assert ( + _text(await victim_after["file_memory_read"].invoke(arguments={"file_name": "secret.md"})) + == "VICTIM_TOKEN=demo-secret" + ) + + +async def test_multi_segment_scope_becomes_a_single_folder() -> None: + """A scope is an opaque key, so it never expands into a nested directory.""" + provider = FileMemoryProvider(store=InMemoryAgentFileStore(), scope="tenants/alice") + folder = provider._resolve_working_folder( # pyright: ignore[reportPrivateUsage] + SessionContext(session_id="session-1", input_messages=[]) + ) + assert "/" not in folder + # ...and it must not share a folder with the sibling tenant. + sibling = FileMemoryProvider(store=InMemoryAgentFileStore(), scope="tenants/bob")._resolve_working_folder( # pyright: ignore[reportPrivateUsage] + SessionContext(session_id="session-1", input_messages=[]) + ) + assert folder != sibling + + +# endregion + + # region file-memory guards diff --git a/python/packages/core/tests/core/test_harness_memory.py b/python/packages/core/tests/core/test_harness_memory.py index 9605b165c6d..d1c7b3dac51 100644 --- a/python/packages/core/tests/core/test_harness_memory.py +++ b/python/packages/core/tests/core/test_harness_memory.py @@ -6,6 +6,7 @@ import json from collections.abc import Mapping, Sequence from datetime import datetime, timedelta, timezone +from pathlib import Path from typing import Any import pytest @@ -27,6 +28,8 @@ Message, ) +from .test_filesystem import COLLIDING_IDENTIFIERS + def _no_store_options() -> ChatOptions: return {"store": False} @@ -213,6 +216,75 @@ async def test_memory_file_store_writes_topics_index_state_and_transcripts(tmp_p ] +async def _write_transcript(store: MemoryFileStore, session: AgentSession, session_id: str, text: str) -> None: + """Append a one-message transcript for ``session_id`` through the real write path.""" + provider = FileHistoryProvider(store.get_transcripts_directory(session, source_id=DEFAULT_MEMORY_SOURCE_ID)) + await provider.save_messages(session_id, [Message(role="user", contents=[text])]) + + +async def test_search_transcripts_finds_session_stored_under_digest_stem(tmp_path) -> None: + """A session ID too long to encode is stored under an irreversible digest stem but stays findable.""" + session = AgentSession(session_id="session-1") + session.state["owner_id"] = "user-1" + store = MemoryFileStore(tmp_path, owner_state_key="owner_id") + long_session_id = "a/" + "a" * 108 + + await _write_transcript(store, session, "short-id", "Short session transcript.") + await _write_transcript(store, session, long_session_id, "Long session transcript.") + + # The long ID must genuinely land on a digest stem, otherwise this test proves nothing. + stem = store._transcript_file_stem(long_session_id) + assert "sha256-" in stem + assert store._decode_transcript_session_id(Path(f"{stem}.jsonl")) is None + + results = store.search_transcripts( + session, source_id=DEFAULT_MEMORY_SOURCE_ID, query="transcript", session_id=long_session_id + ) + assert [(result["session_id"], result["text"]) for result in results] == [ + (long_session_id, "Long session transcript.") + ] + + results = store.search_transcripts( + session, source_id=DEFAULT_MEMORY_SOURCE_ID, query="transcript", session_id="short-id" + ) + assert [(result["session_id"], result["text"]) for result in results] == [("short-id", "Short session transcript.")] + + unfiltered = store.search_transcripts(session, source_id=DEFAULT_MEMORY_SOURCE_ID, query="transcript") + assert {result["text"] for result in unfiltered} == {"Short session transcript.", "Long session transcript."} + + +async def test_search_transcripts_isolates_colliding_session_ids(tmp_path) -> None: + """Session IDs that normalize alike must each retrieve only their own transcript.""" + session = AgentSession(session_id="session-1") + session.state["owner_id"] = "user-1" + store = MemoryFileStore(tmp_path, owner_state_key="owner_id") + + for index, candidate in enumerate(COLLIDING_IDENTIFIERS): + await _write_transcript(store, session, candidate, f"Transcript {index}.") + + for index, candidate in enumerate(COLLIDING_IDENTIFIERS): + results = store.search_transcripts( + session, source_id=DEFAULT_MEMORY_SOURCE_ID, query="Transcript", session_id=candidate + ) + assert [result["text"] for result in results] == [f"Transcript {index}."] + assert all(result["session_id"] == candidate for result in results) + + +async def test_search_transcripts_returns_nothing_for_unknown_session(tmp_path) -> None: + """Filtering by a session that never wrote a transcript should return no results.""" + session = AgentSession(session_id="session-1") + session.state["owner_id"] = "user-1" + store = MemoryFileStore(tmp_path, owner_state_key="owner_id") + await _write_transcript(store, session, "known-id", "Known transcript.") + + assert ( + store.search_transcripts( + session, source_id=DEFAULT_MEMORY_SOURCE_ID, query="transcript", session_id="unknown-id" + ) + == [] + ) + + def test_memory_file_store_rejects_owner_path_traversal(tmp_path) -> None: """Owner IDs with path traversal segments should not escape ``base_path``.""" session = AgentSession(session_id="session-1") @@ -875,3 +947,62 @@ def test_extract_keywords_handles_non_english_text() -> None: assert cyrillic == {"привет", "мир", "друзья"} # English extraction is unchanged. assert english == {"hello", "world"} + + +# region Storage-key parity: identifiers must map injectively onto directories + + +def test_memory_file_store_derives_distinct_roots_for_colliding_owner_ids(tmp_path) -> None: + """Owner IDs that a path normalizer would fold together stay separate. + + ``MemoryFileStore`` shares the storage-key derivation with the session + store, the todo store, and the file-memory provider, so it is held to the + same injectivity contract. Owner IDs that trip the independent traversal + guard are rejected outright rather than merged. + """ + store = MemoryFileStore(tmp_path, owner_state_key="owner_id") + roots: dict[str, Path] = {} + rejected: set[str] = set() + for owner_id in COLLIDING_IDENTIFIERS: + session = AgentSession(session_id="session-1") + session.state["owner_id"] = owner_id + try: + roots[owner_id] = store._get_memory_root(session, source_id=DEFAULT_MEMORY_SOURCE_ID) + except ValueError: + rejected.add(owner_id) + + assert rejected, "the traversal guard should still reject absolute and '..' owner IDs" + assert len(set(roots.values())) == len(roots), roots + assert len({str(root).lower() for root in roots.values()}) == len(roots), roots + assert len(roots) + len(rejected) == len(COLLIDING_IDENTIFIERS) + for root in roots.values(): + assert root.is_relative_to(tmp_path.resolve()) + + +def test_memory_file_store_encodes_non_ascii_owner_ids(tmp_path) -> None: + """NFC and NFD spellings of one word must not share a directory.""" + store = MemoryFileStore(tmp_path, owner_state_key="owner_id") + roots: list[Path] = [] + for owner_id in ("caf\u00e9", "cafe\u0301"): + session = AgentSession(session_id="session-1") + session.state["owner_id"] = owner_id + root = store._get_memory_root(session, source_id=DEFAULT_MEMORY_SOURCE_ID) + assert all(part.isascii() for part in root.relative_to(tmp_path.resolve()).parts) + roots.append(root) + + assert roots[0] != roots[1] + + +def test_memory_file_store_uses_literal_folders_for_safe_identifiers(tmp_path) -> None: + """Safe identifiers are readable on disk rather than opaque encoded segments.""" + store = MemoryFileStore(tmp_path, owner_prefix="user_", owner_state_key="owner_id") + session = AgentSession(session_id="session-1") + session.state["owner_id"] = "alice" + + root = store._get_memory_root(session, source_id="memory") + parts = root.relative_to(tmp_path.resolve()).parts + assert parts[0] == "memory" + assert parts[1] == "user_alice" + + +# endregion diff --git a/python/packages/core/tests/core/test_harness_todo.py b/python/packages/core/tests/core/test_harness_todo.py index 891ea7d94df..2b76c1b49ce 100644 --- a/python/packages/core/tests/core/test_harness_todo.py +++ b/python/packages/core/tests/core/test_harness_todo.py @@ -23,6 +23,8 @@ ) from agent_framework._harness._todo import TodoInput +from .test_filesystem import COLLIDING_IDENTIFIERS + def _tool_by_name(tools: list[object], name: str) -> object: """Return the tool with the requested name from a prepared tool list.""" @@ -377,3 +379,50 @@ def test_todo_harness_graduated_classes_are_not_experimental() -> None: assert TodoFileStore.__feature_id__ == ExperimentalFeature.HARNESS.value # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] assert TodoFileStore.__doc__ is not None assert ".. warning:: Experimental" in TodoFileStore.__doc__ + + +def test_todo_file_store_derives_distinct_directories_for_colliding_owner_ids(tmp_path: Path) -> None: + """Owner IDs that a path normalizer would fold together stay separate. + + ``TodoFileStore`` shares the storage-key derivation with the session store, + the memory store, and the file-memory provider, so it is held to the same + injectivity contract. + """ + store = TodoFileStore(tmp_path, owner_state_key="owner_id") + paths: dict[str, Path] = {} + for owner_id in COLLIDING_IDENTIFIERS: + session = AgentSession(session_id="session-1") + session.state["owner_id"] = owner_id + paths[owner_id] = store._get_state_path(session, source_id="todo") # pyright: ignore[reportPrivateUsage] + + assert len(set(paths.values())) == len(COLLIDING_IDENTIFIERS), paths + assert len({str(path).lower() for path in paths.values()}) == len(COLLIDING_IDENTIFIERS), paths + for path in paths.values(): + assert path.is_relative_to(tmp_path.resolve()) + + +def test_todo_file_store_encodes_non_ascii_owner_ids(tmp_path: Path) -> None: + """Non-ASCII IDs are encoded, not used verbatim. + + A literal non-ASCII directory name is folded onto a single entry by macOS + APFS/HFS+, so the NFC and NFD spellings of one word would otherwise share a + directory despite being byte-distinct. + """ + store = TodoFileStore(tmp_path, owner_state_key="owner_id") + paths: list[Path] = [] + for owner_id in ("caf\u00e9", "cafe\u0301"): + session = AgentSession(session_id="session-1") + session.state["owner_id"] = owner_id + path = store._get_state_path(session, source_id="todo") # pyright: ignore[reportPrivateUsage] + assert path.parent.parent.name.isascii() + paths.append(path) + + assert paths[0] != paths[1] + + +def test_todo_file_store_encodes_windows_reserved_stems_with_an_extension(tmp_path: Path) -> None: + """``CON.txt`` still resolves to the reserved console device on Windows.""" + session = AgentSession(session_id="CON.txt") + store = TodoFileStore(tmp_path) + path = store._get_state_path(session, source_id="todo") # pyright: ignore[reportPrivateUsage] + assert path.parent.name.startswith("~todo-") diff --git a/python/packages/core/tests/core/test_sessions.py b/python/packages/core/tests/core/test_sessions.py index 9b789496a09..27028dc7315 100644 --- a/python/packages/core/tests/core/test_sessions.py +++ b/python/packages/core/tests/core/test_sessions.py @@ -48,6 +48,8 @@ from agent_framework._telemetry import FeatureIndex from agent_framework.exceptions import MiddlewareException +from .test_filesystem import COLLIDING_IDENTIFIERS + if TYPE_CHECKING: from agent_framework._agents import SupportsAgentRun @@ -1298,6 +1300,31 @@ async def test_reserved_windows_filename_is_encoded(self, tmp_path: Path, sessio assert session_file.name.startswith("~session-") assert session_file.is_file() + def test_colliding_session_ids_get_distinct_history_files(self, tmp_path: Path) -> None: + """Session IDs that a path normalizer would fold together stay separate. + + ``FileHistoryProvider`` shares the storage-key derivation with the todo + store, the memory store, and the file-memory provider, so it is held to + the same injectivity contract. + """ + provider = FileHistoryProvider(tmp_path) + paths = {session_id: provider._session_file_path(session_id) for session_id in COLLIDING_IDENTIFIERS} + + assert len(set(paths.values())) == len(COLLIDING_IDENTIFIERS), paths + assert len({str(path).lower() for path in paths.values()}) == len(COLLIDING_IDENTIFIERS), paths + for path in paths.values(): + assert path.parent == tmp_path.resolve() + + def test_non_ascii_session_ids_are_encoded(self, tmp_path: Path) -> None: + """NFC and NFD spellings of one word must not share a history file.""" + provider = FileHistoryProvider(tmp_path) + nfc = provider._session_file_path("caf\u00e9") + nfd = provider._session_file_path("cafe\u0301") + + assert nfc != nfd + assert nfc.name.isascii() + assert nfd.name.isascii() + # --------------------------------------------------------------------------- # InMemoryHistoryProvider tests diff --git a/python/packages/core/tests/core/test_skills.py b/python/packages/core/tests/core/test_skills.py index b1e98c8a708..237b723841b 100644 --- a/python/packages/core/tests/core/test_skills.py +++ b/python/packages/core/tests/core/test_skills.py @@ -1129,7 +1129,7 @@ def test_entry_that_cannot_be_inspected_is_skipped(self, tmp_path: Path) -> None def _raise(path: Path) -> bool: raise OSError("cannot inspect") - with patch("agent_framework._skills.is_link_or_reparse_point", side_effect=_raise): + with patch("agent_framework._skills._is_link_or_reparse_point", side_effect=_raise): assert FileSkillsSource._discover_skill_directories([str(root)]) == [] def test_skill_file_that_cannot_be_inspected_is_skipped(self, tmp_path: Path) -> None: @@ -1142,7 +1142,7 @@ def _raise_for_skill_file(path: Path) -> bool: raise OSError("cannot inspect") return False - with patch("agent_framework._skills.is_link_or_reparse_point", side_effect=_raise_for_skill_file): + with patch("agent_framework._skills._is_link_or_reparse_point", side_effect=_raise_for_skill_file): assert FileSkillsSource._discover_skill_directories([str(root)]) == [] @@ -2035,7 +2035,7 @@ def test_fails_closed_when_path_cannot_be_inspected(self, tmp_path: Path, monkey def fail_probe(path: Path) -> bool: raise PermissionError(path) - monkeypatch.setattr("agent_framework._skills.is_link_or_reparse_point", fail_probe) + monkeypatch.setattr("agent_framework._skills._is_link_or_reparse_point", fail_probe) assert FileSkillsSource._has_link_or_reparse_point_in_path(str(target), str(tmp_path)) is True diff --git a/python/packages/gemini/tests/test_gemini_client.py b/python/packages/gemini/tests/test_gemini_client.py index f9f3f774d3d..a2b4908d7ad 100644 --- a/python/packages/gemini/tests/test_gemini_client.py +++ b/python/packages/gemini/tests/test_gemini_client.py @@ -405,9 +405,7 @@ async def test_get_response_returns_text() -> None: (RuntimeError("connection reset"), ChatClientException), ], ) -async def test_get_response_wraps_sdk_errors( - sdk_exception: Exception, expected_exception: type[Exception] -) -> None: +async def test_get_response_wraps_sdk_errors(sdk_exception: Exception, expected_exception: type[Exception]) -> None: """Non-streaming get_response must translate raw google-genai SDK errors into the framework's ChatClientException hierarchy, matching every other provider (OpenAI, Anthropic, Mistral, Ollama, Bedrock).""" diff --git a/python/samples/02-agents/context_providers/file_memory_provider.py b/python/samples/02-agents/context_providers/file_memory_provider.py index 776dd5f24aa..7574fb72c63 100644 --- a/python/samples/02-agents/context_providers/file_memory_provider.py +++ b/python/samples/02-agents/context_providers/file_memory_provider.py @@ -38,6 +38,16 @@ session for that user shares — which is what lets the second conversation below recall what the user said in the first. + A scope is an opaque namespace key, not a path. It is mapped to exactly one + folder, and two byte-distinct scopes do not share a working folder, so a + value containing separators becomes a single encoded folder rather than a + nested directory. Prefer a flat, canonical, lowercase scope such as + ``f"user-{USER_ID}"``; values outside the safe lowercase set are encoded, so + the folder name stays unique but is no longer readable. If the scope comes + from an external request, keep authorizing it in your own application code — + the storage mapping is a storage-layer guarantee, not an authorization + check. + Prerequisites: - ``FOUNDRY_PROJECT_ENDPOINT``: Your Microsoft Foundry project endpoint. - ``FOUNDRY_MODEL``: Chat model deployment name. @@ -45,8 +55,9 @@ """ # The id of the user we are storing memories for. It is used below as the -# provider's scope so that each user gets their own memory folder. -USER_ID = "UID1" +# provider's scope so that each user gets their own memory folder. Keep it +# lowercase so the folder name stays readable rather than encoded. +USER_ID = "uid1" async def main() -> None: @@ -71,7 +82,10 @@ async def main() -> None: # conversation further down to recall what the user said in the first. # - Omitting ``scope`` (the default) isolates memories to a single session # (the working folder is derived from the session id). - file_memory_provider = FileMemoryProvider(store, scope=f"users/{USER_ID}") + # Keep the scope a flat, canonical, lowercase value: it is an opaque key + # mapped onto exactly one folder, not a path that expands into + # subdirectories. + file_memory_provider = FileMemoryProvider(store, scope=f"user-{USER_ID}") # 3. Attach the provider to the agent so it gets the file_memory_* tools. agent = Agent( @@ -85,7 +99,7 @@ async def main() -> None: ) # - working_folder = memory_root / "users" / USER_ID + working_folder = memory_root / f"user-{USER_ID}" print(f"Memory files will be written to: {working_folder}\n") # 4. First conversation: tell the agent something worth remembering. The @@ -129,7 +143,7 @@ async def main() -> None: """ Sample output (abridged; exact text varies by model): -Memory files will be written to: .../context_providers/agent-file-memory/users/UID1 +Memory files will be written to: .../context_providers/agent-file-memory/user-uid1 === First conversation === Got it — I'll remember that you're vegetarian and always travel with your dog. I've saved From 6f099737a6ebf896437b8938feeb70e83d6fa583 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 8 Sep 2026 16:57:34 +0200 Subject: [PATCH 85/85] .NET: Isolate LocalCodeAct subprocess environment Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Internal/ProcessBridge.cs | 16 ++-- .../LocalCodeActProviderOptions.cs | 7 +- .../README.md | 3 +- ...ocalExecuteCodeFunctionIntegrationTests.cs | 78 +++++++++++++++++++ 4 files changed, 89 insertions(+), 15 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/ProcessBridge.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/ProcessBridge.cs index f0a4498f8f4..dee86154c38 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/ProcessBridge.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Internal/ProcessBridge.cs @@ -106,18 +106,14 @@ public async Task RunAsync(string code, CancellationToken cance private void ConfigureEnvironment(ProcessStartInfo startInfo) { - // Null => inherit the parent environment (documented contract on - // LocalCodeActProviderOptions.Environment). Callers wanting a scrubbed - // environment pass an empty dictionary. - if (this._environment is null) - { - return; - } - startInfo.Environment.Clear(); - foreach (var kvp in this._environment) + + if (this._environment is not null) { - startInfo.Environment[kvp.Key] = kvp.Value; + foreach (var kvp in this._environment) + { + startInfo.Environment[kvp.Key] = kvp.Value; + } } // Without these on Windows, Python may fail to load its standard library. diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProviderOptions.cs index 1dc86108abf..208b9fe24f5 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProviderOptions.cs @@ -27,10 +27,9 @@ public sealed class LocalCodeActProviderOptions /// Gets or sets environment variables passed to the subprocess. /// /// - /// When , the subprocess inherits the parent process environment - /// (the default behavior). To run with - /// a restricted environment, supply a dictionary containing only the variables the - /// subprocess should see — pass an empty dictionary for a fully scrubbed environment. + /// The subprocess does not inherit the parent process environment. When this property is + /// or empty, the subprocess runs with a scrubbed environment. + /// Otherwise, the subprocess receives only the variables in the supplied dictionary. /// On Windows, a small set of system variables (SYSTEMROOT, SYSTEMDRIVE, COMSPEC, /// PATHEXT, TEMP, TMP) is back-filled from the parent environment when not already /// present so Python can locate its standard library. diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md index 13ca7c38477..ef212e27115 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md @@ -156,7 +156,8 @@ scanned for **new** files after execution, and those files are returned as ## Environment Variables Pass environment variables explicitly. The subprocess does NOT inherit the host -environment by default: +environment by default. On Windows, the system variables required for Python to +load its standard library are retained: ```csharp using var provider = new LocalCodeActProvider("/usr/bin/python3", new LocalCodeActProviderOptions diff --git a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionIntegrationTests.cs index 45952809de8..c95ce790862 100644 --- a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionIntegrationTests.cs @@ -108,6 +108,84 @@ public async Task ExecuteCode_AllowsPermittedOsAccessAsync(string code) Assert.NotNull(result); } + [Fact] + public async Task ExecuteCode_DefaultEnvironmentDoesNotInheritParentVariablesAsync() + { + SkipIfNoPython(); + + // Arrange + var variableName = $"AF_LOCALCODEACT_PARENT_{Guid.NewGuid():N}"; + var parentValue = $"parent-value-{Guid.NewGuid():N}"; + var originalValue = Environment.GetEnvironmentVariable(variableName); + Environment.SetEnvironmentVariable(variableName, parentValue); + + try + { + var function = new LocalExecuteCodeFunction(s_python!); + var args = new AIFunctionArguments + { + ["code"] = $"import os\nprint(os.environ.get('{variableName}', 'NOT_FOUND'))", + }; + + // Act + var result = await function.InvokeAsync(args, CancellationToken.None); + + // Assert + var combined = GetResultText(result); + Assert.Contains("NOT_FOUND", combined, StringComparison.Ordinal); + Assert.DoesNotContain(parentValue, combined, StringComparison.Ordinal); + } + finally + { + Environment.SetEnvironmentVariable(variableName, originalValue); + } + } + + [Fact] + public async Task ExecuteCode_ExplicitEnvironmentDoesNotInheritOtherParentVariablesAsync() + { + SkipIfNoPython(); + + // Arrange + var parentVariableName = $"AF_LOCALCODEACT_PARENT_{Guid.NewGuid():N}"; + var childVariableName = $"AF_LOCALCODEACT_CHILD_{Guid.NewGuid():N}"; + var parentValue = $"parent-value-{Guid.NewGuid():N}"; + var childValue = $"child-value-{Guid.NewGuid():N}"; + var originalValue = Environment.GetEnvironmentVariable(parentVariableName); + Environment.SetEnvironmentVariable(parentVariableName, parentValue); + + try + { + var options = new LocalCodeActProviderOptions + { + Environment = new Dictionary + { + [childVariableName] = childValue, + }, + }; + var function = new LocalExecuteCodeFunction(s_python!, options); + var args = new AIFunctionArguments + { + ["code"] = + $"import os\nprint(os.environ.get('{childVariableName}', 'NOT_FOUND'))\n" + + $"print(os.environ.get('{parentVariableName}', 'NOT_FOUND'))", + }; + + // Act + var result = await function.InvokeAsync(args, CancellationToken.None); + + // Assert + var combined = GetResultText(result); + Assert.Contains(childValue, combined, StringComparison.Ordinal); + Assert.Contains("NOT_FOUND", combined, StringComparison.Ordinal); + Assert.DoesNotContain(parentValue, combined, StringComparison.Ordinal); + } + finally + { + Environment.SetEnvironmentVariable(parentVariableName, originalValue); + } + } + [Fact] public async Task ExecuteCode_CapturesFilesInWritableMountAsync() {